From 4341bd15b90d6edec74fe5e5289af62192fd124b Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 31 Mar 2026 11:56:04 -0500 Subject: [PATCH 1/4] feat(SearchModal): Add search mode --- src/components/SearchModal.tsx | 458 ++++++++++++++++++++++++++++----- 1 file changed, 395 insertions(+), 63 deletions(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 0d611fe0..3e900b31 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -33,6 +33,41 @@ import { type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' | 'minesweeper' +type SearchMode = + | 'default' + | 'command-root' + | 'games' + | 'docs' + | 'unknown-command' + +type ParsedSearch = + | { mode: 'default'; term: string } + | { mode: 'command-root'; term: string } + | { mode: 'games'; term: string } + | { mode: 'docs'; term: string } + | { mode: 'unknown-command'; command: string; term: string } + +const COMMANDS = [ + { + key: 'games', + label: '!games', + description: 'Browse and launch games', + }, + { + key: 'docs', + label: '!docs', + description: 'Search docs by title or content', + }, +] as const + +const GAMES: { key: ArcadeGame; label: string; description: string }[] = [ + { key: 'snake', label: 'Snake', description: 'Classic snake game' }, + { key: 'asteroids', label: 'Asteroids', description: 'Arcade space shooter' }, + { key: 'racecar', label: 'Race Car', description: 'Driving game' }, + { key: 'tetris', label: 'Tetris', description: 'Block puzzle game' }, + { key: 'minesweeper', label: 'Minesweeper', description: 'Find all mines' }, +] + // ---- type icon mapping ------------------------------------------------ const TypeIcon = ({ group }: { group: GroupType }) => { @@ -100,7 +135,12 @@ const RelatedThings = ({ /> ))} {things.length > 4 && ( - + )} ) @@ -145,7 +185,11 @@ const ResultRow = ({ {highlight(option.label, query)} {subtitle && ( - + {subtitle} )} @@ -174,20 +218,50 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { const [activeGame, setActiveGame] = useState(null) const [dismissedGame, setDismissedGame] = useState(null) const debounced = useDebounce(query, 400) + const normalizedQuery = query.trim().toLowerCase() const normalizedDebounced = debounced.trim().toLowerCase() - const requestedGame = - normalizedQuery === 'snake' - ? 'snake' - : normalizedQuery === 'asteroids' - ? 'asteroids' - : normalizedQuery === 'racecar' - ? 'racecar' - : normalizedQuery === 'tetris' - ? 'tetris' - : normalizedQuery === 'minesweeper' - ? 'minesweeper' - : null + + const parsed: ParsedSearch = useMemo(() => { + const trimmed = query.trim() + + if (!trimmed.startsWith('!')) { + return { mode: 'default', term: trimmed } + } + + const withoutBang = trimmed.slice(1).trim() + + if (!withoutBang) { + return { mode: 'command-root', term: '' } + } + + const [command, ...rest] = withoutBang.split(/\s+/) + const commandTerm = rest.join(' ').trim() + + if (command.toLowerCase() === 'games') { + return { mode: 'games', term: commandTerm } + } + + if (command.toLowerCase() === 'docs') { + return { mode: 'docs', term: commandTerm } + } + + return { + mode: 'unknown-command', + command, + term: commandTerm, + } + }, [query]) + + const requestedGame: ArcadeGame | null = useMemo(() => { + if (parsed.mode !== 'games') return null + + const normalizedTerm = parsed.term.trim().toLowerCase() + if (!normalizedTerm) return null + + const exactMatch = GAMES.find((game) => game.key === normalizedTerm) + return exactMatch?.key ?? null + }, [parsed]) // Reload history each time modal opens useEffect(() => { @@ -211,7 +285,11 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { return } - if (open && activeGame !== requestedGame && dismissedGame !== requestedGame) { + if ( + open && + activeGame !== requestedGame && + dismissedGame !== requestedGame + ) { setActiveGame(requestedGame) } }, [activeGame, dismissedGame, open, requestedGame]) @@ -223,12 +301,8 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { queryOptions: { enabled: open && - normalizedDebounced.length >= 1 && - normalizedDebounced !== 'snake' && - normalizedDebounced !== 'asteroids' && - normalizedDebounced !== 'racecar' && - normalizedDebounced !== 'tetris' && - normalizedDebounced !== 'minesweeper', + ((parsed.mode === 'default' && normalizedDebounced.length >= 1) || + (parsed.mode === 'docs' && debounced.trim().length > 0)), staleTime: 120_000, refetchOnReconnect: false, refetchOnWindowFocus: false, @@ -237,9 +311,11 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { }) const results: SearchResult[] = useMemo(() => { + if (parsed.mode !== 'default') return [] if (!query.trim()) return [] if (searchQuery.isFetching) return [] if (searchQuery.isError) return [] + const normalized = result?.data?.map((r: any) => ({ label: r.label, @@ -247,8 +323,9 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { group: r.group as GroupType, properties: r.properties, })) ?? [] + return dedupeResults(normalized) - }, [result, query, searchQuery.isFetching, searchQuery.isError]) + }, [parsed.mode, result, query, searchQuery.isFetching, searchQuery.isError]) // Group results by type for section headers const grouped = useMemo(() => { @@ -260,6 +337,24 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { return map }, [results]) + const filteredCommands = useMemo(() => { + if (parsed.mode !== 'command-root') return [] + + return COMMANDS + }, [parsed.mode]) + + const filteredGames = useMemo(() => { + if (parsed.mode !== 'games') return [] + + const term = parsed.term.trim().toLowerCase() + if (!term) return GAMES + + return GAMES.filter( + (game) => + game.key.includes(term) || game.label.toLowerCase().includes(term) + ) + }, [parsed]) + const navigateToResult = (option: SearchResult) => { switch (option.group) { case GroupType.Wells: @@ -268,7 +363,9 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { const isWaterWell = p.thing_type === 'water well' go({ to: { - resource: isWaterWell ? 'ocotillo.thing-well' : 'ocotillo.thing-spring', + resource: isWaterWell + ? 'ocotillo.thing-well' + : 'ocotillo.thing-spring', action: 'show', id: p.id, }, @@ -277,17 +374,34 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { } case GroupType.Contacts: go({ - to: { resource: 'ocotillo.contact', action: 'show', id: (option as ContactResult).properties.id }, + to: { + resource: 'ocotillo.contact', + action: 'show', + id: (option as ContactResult).properties.id, + }, }) break case GroupType.Assets: go({ - to: { resource: 'ocotillo.asset', action: 'show', id: (option as any).properties.id }, + to: { + resource: 'ocotillo.asset', + action: 'show', + id: (option as any).properties.id, + }, }) break } } + const handleCommandClick = (command: 'games' | 'docs') => { + setQuery(`!${command} `) + inputRef.current?.focus() + } + + const handleGameSelect = (game: ArcadeGame) => { + setActiveGame(game) + } + const handleSelect = (option: SearchResult) => { navigateToResult(option) handleClose() @@ -317,13 +431,25 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { } const showRecent = !query.trim() && recentSearches.length > 0 - const showEmpty = + + const showDefaultEmpty = + parsed.mode === 'default' && query.trim() && - !requestedGame && !searchQuery.isFetching && !searchQuery.isError && results.length === 0 - const showError = query.trim() && !requestedGame && !searchQuery.isFetching && searchQuery.isError + + const showDocsEmpty = + parsed.mode === 'docs' && + parsed.term.trim() && + !searchQuery.isFetching && + !searchQuery.isError + + const showError = + (parsed.mode === 'default' || parsed.mode === 'docs') && + query.trim() && + !searchQuery.isFetching && + searchQuery.isError return ( <> @@ -334,10 +460,16 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { maxWidth="sm" sx={{ '& .MuiDialog-container': { alignItems: 'flex-start', pt: 2 }, - '& .MuiDialog-paper': { borderRadius: 2, overflow: 'hidden', mx: { xs: 0.5, sm: 'auto' } }, + '& .MuiDialog-paper': { + borderRadius: 2, + overflow: 'hidden', + mx: { xs: 0.5, sm: 'auto' }, + }, }} slotProps={{ - backdrop: { sx: { backdropFilter: 'blur(2px)', bgcolor: 'rgba(0,0,0,0.8)' } }, + backdrop: { + sx: { backdropFilter: 'blur(2px)', bgcolor: 'rgba(0,0,0,0.8)' }, + }, }} > {/* Search input row */} @@ -357,15 +489,45 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { inputRef={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Escape') handleClose() }} - placeholder="Search" + onKeyDown={(e) => { + if (e.key === 'Escape') { + handleClose() + return + } + + if (e.key === 'Enter') { + if (parsed.mode === 'command-root') { + handleCommandClick('games') + return + } + + if (parsed.mode === 'games' && filteredGames.length > 0) { + handleGameSelect(filteredGames[0].key) + return + } + + // if (parsed.mode === 'docs' && docsResults.length > 0) { + // handleSelect(docsResults[0]) + // return + // } + + if (parsed.mode === 'default' && results.length > 0) { + handleSelect(results[0]) + } + } + }} + placeholder='Search or type "!" for commands' fullWidth sx={{ fontSize: 15 }} inputProps={{ 'aria-label': 'Search' }} endAdornment={ query ? ( - setQuery('')} edge="end"> + setQuery('')} + edge="end" + > @@ -375,32 +537,158 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { {/* Results area */} - + {parsed.mode === 'command-root' && ( + + + + Commands + + + + {filteredCommands.map((command: any) => ( + handleCommandClick(command.key)} + sx={{ + display: 'flex', + alignItems: 'flex-start', + gap: 1.5, + px: 1.5, + py: 1, + borderRadius: 1, + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + + {command.label} + + + {command.description} + + + + ))} + + )} + + {parsed.mode === 'games' && !requestedGame && ( + + + + Games + + + + {filteredGames.map((game) => ( + handleGameSelect(game.key)} + sx={{ + display: 'flex', + alignItems: 'flex-start', + gap: 1.5, + px: 1.5, + py: 1, + borderRadius: 1, + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + {game.label} + + + {game.description} + + + + ))} + + {filteredGames.length === 0 && ( + + No games found for "{parsed.term}". + + )} + + )} + {/* Loading indicator */} {searchQuery.isFetching && ( - + Searching... )} {requestedGame && ( - - Opening {requestedGame === 'snake' ? 'Snake' : requestedGame === 'asteroids' ? 'Asteroids' : requestedGame === 'racecar' ? 'Race Car' : requestedGame === 'tetris' ? 'Tetris' : 'Minesweeper'}... + + Press Enter to open{' '} + {GAMES.find((g) => g.key === requestedGame)?.label ?? + requestedGame} + . )} {/* Recent searches */} {showRecent && ( - - + + Recent searches Clear history @@ -421,7 +709,9 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { '&:hover': { bgcolor: 'action.hover' }, }} > - + {q} @@ -431,45 +721,87 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { )} {/* Empty state */} - {showEmpty && ( - - No results for "{query}". Try a well ID, site name, or contact name. + {showDefaultEmpty && ( + + No results for "{query}". Try a well ID, site name, or contact + name. + + )} + + {showDocsEmpty && ( + + No docs found for "{parsed.term}". )} {/* Error state */} {showError && ( - + Search failed. Please try again. )} + {parsed.mode === 'unknown-command' && ( + + Unknown command "!{parsed.command}". Try !games or !docs. + + )} + {/* Grouped results */} {!searchQuery.isFetching && !requestedGame && grouped.size > 0 && ( - {Array.from(grouped.entries()).map(([group, items], groupIndex) => ( - - {groupIndex > 0 && } - {items.map((option, i) => ( - handleSelect(option)} - /> - ))} - - ))} + {Array.from(grouped.entries()).map( + ([group, items], groupIndex) => ( + + {groupIndex > 0 && } + {items.map((option, i) => ( + handleSelect(option)} + /> + ))} + + ) + )} )} - - - - - + + + + ) } From 8f93ff64a9fdf69493fdcd6647d6334203e7d036 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 31 Mar 2026 12:18:30 -0500 Subject: [PATCH 2/4] fix(SearchModal): Patch docs search --- src/components/SearchModal.tsx | 140 +++++++++++++++++++---- src/pages/content/index.tsx | 109 ++++++++++++------ src/test/components/SearchModal.test.tsx | 67 ++++++++++- src/utils/docsSearch.ts | 83 ++++++++++++++ 4 files changed, 341 insertions(+), 58 deletions(-) create mode 100644 src/utils/docsSearch.ts diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 3e900b31..616fe823 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -22,6 +22,7 @@ import { useGo } from '@refinedev/core' import { useDebounce, useAbortableList, useSearchHistory } from '@/hooks' import { GroupType } from '@/constants' import { SearchResult, WellResult, ContactResult } from '@/interfaces/ocotillo' +import { DocEntry, searchDocs } from '@/utils/docsSearch' import { highlight } from '@/utils' import { SnakeGameModal, @@ -68,6 +69,21 @@ const GAMES: { key: ArcadeGame; label: string; description: string }[] = [ { key: 'minesweeper', label: 'Minesweeper', description: 'Find all mines' }, ] +const buildDocExcerpt = (doc: DocEntry, query: string) => { + const trimmedQuery = query.trim().toLowerCase() + if (!trimmedQuery) return doc.path + + const normalizedContent = doc.content.toLowerCase() + const matchIndex = normalizedContent.indexOf(trimmedQuery) + if (matchIndex === -1) return doc.path + + const start = Math.max(0, matchIndex - 40) + const end = Math.min(doc.content.length, matchIndex + trimmedQuery.length + 80) + const excerpt = doc.content.slice(start, end).replace(/\s+/g, ' ').trim() + + return `${doc.path} · ${excerpt}${end < doc.content.length ? '...' : ''}` +} + // ---- type icon mapping ------------------------------------------------ const TypeIcon = ({ group }: { group: GroupType }) => { @@ -201,6 +217,46 @@ const ResultRow = ({ ) } +const DocResultRow = ({ + option, + query, + onClick, +}: { + option: DocEntry + query: string + onClick: () => void +}) => ( + + + + + {highlight(option.title, query)} + + + {highlight(buildDocExcerpt(option, query), query)} + + + +) + // ---- main modal ------------------------------------------------------- type SearchModalProps = { @@ -229,23 +285,32 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { return { mode: 'default', term: trimmed } } - const withoutBang = trimmed.slice(1).trim() + const withoutBang = trimmed.slice(1).trimStart() - if (!withoutBang) { + if (!withoutBang.trim()) { return { mode: 'command-root', term: '' } } const [command, ...rest] = withoutBang.split(/\s+/) const commandTerm = rest.join(' ').trim() + const normalizedCommand = command.toLowerCase() - if (command.toLowerCase() === 'games') { + if (normalizedCommand === 'games') { return { mode: 'games', term: commandTerm } } - if (command.toLowerCase() === 'docs') { + if (normalizedCommand === 'docs') { return { mode: 'docs', term: commandTerm } } + const partialMatches = COMMANDS.filter((item) => + item.key.startsWith(normalizedCommand) + ) + + if (partialMatches.length > 0 && !commandTerm) { + return { mode: 'command-root', term: normalizedCommand } + } + return { mode: 'unknown-command', command, @@ -254,14 +319,16 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { }, [query]) const requestedGame: ArcadeGame | null = useMemo(() => { - if (parsed.mode !== 'games') return null + const normalizedTerm = + parsed.mode === 'games' + ? parsed.term.trim().toLowerCase() + : normalizedQuery - const normalizedTerm = parsed.term.trim().toLowerCase() if (!normalizedTerm) return null const exactMatch = GAMES.find((game) => game.key === normalizedTerm) return exactMatch?.key ?? null - }, [parsed]) + }, [normalizedQuery, parsed]) // Reload history each time modal opens useEffect(() => { @@ -300,9 +367,7 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { pagination: { pageSize: 100 }, queryOptions: { enabled: - open && - ((parsed.mode === 'default' && normalizedDebounced.length >= 1) || - (parsed.mode === 'docs' && debounced.trim().length > 0)), + open && parsed.mode === 'default' && normalizedDebounced.length >= 1, staleTime: 120_000, refetchOnReconnect: false, refetchOnWindowFocus: false, @@ -340,8 +405,11 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { const filteredCommands = useMemo(() => { if (parsed.mode !== 'command-root') return [] - return COMMANDS - }, [parsed.mode]) + const term = parsed.term.trim().toLowerCase() + if (!term) return COMMANDS + + return COMMANDS.filter((command) => command.key.startsWith(term)) + }, [parsed]) const filteredGames = useMemo(() => { if (parsed.mode !== 'games') return [] @@ -355,6 +423,12 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { ) }, [parsed]) + const docsResults = useMemo(() => { + if (parsed.mode !== 'docs') return [] + + return searchDocs(parsed.term) + }, [parsed]) + const navigateToResult = (option: SearchResult) => { switch (option.group) { case GroupType.Wells: @@ -398,6 +472,11 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { inputRef.current?.focus() } + const handleDocSelect = (doc: DocEntry) => { + go({ to: doc.route, type: 'push' }) + handleClose() + } + const handleGameSelect = (game: ArcadeGame) => { setActiveGame(game) } @@ -440,13 +519,10 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { results.length === 0 const showDocsEmpty = - parsed.mode === 'docs' && - parsed.term.trim() && - !searchQuery.isFetching && - !searchQuery.isError + parsed.mode === 'docs' && docsResults.length === 0 const showError = - (parsed.mode === 'default' || parsed.mode === 'docs') && + parsed.mode === 'default' && query.trim() && !searchQuery.isFetching && searchQuery.isError @@ -496,8 +572,8 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { } if (e.key === 'Enter') { - if (parsed.mode === 'command-root') { - handleCommandClick('games') + if (parsed.mode === 'command-root' && filteredCommands.length > 0) { + handleCommandClick(filteredCommands[0].key) return } @@ -506,10 +582,10 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { return } - // if (parsed.mode === 'docs' && docsResults.length > 0) { - // handleSelect(docsResults[0]) - // return - // } + if (parsed.mode === 'docs' && docsResults.length > 0) { + handleDocSelect(docsResults[0]) + return + } if (parsed.mode === 'default' && results.length > 0) { handleSelect(results[0]) @@ -763,8 +839,24 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { )} + {parsed.mode === 'docs' && docsResults.length > 0 && ( + + {docsResults.map((doc) => ( + handleDocSelect(doc)} + /> + ))} + + )} + {/* Grouped results */} - {!searchQuery.isFetching && !requestedGame && grouped.size > 0 && ( + {!searchQuery.isFetching && + !requestedGame && + parsed.mode === 'default' && + grouped.size > 0 && ( {Array.from(grouped.entries()).map( ([group, items], groupIndex) => ( diff --git a/src/pages/content/index.tsx b/src/pages/content/index.tsx index 4ef25066..ff296266 100644 --- a/src/pages/content/index.tsx +++ b/src/pages/content/index.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown' import { Box, CircularProgress, Divider, Typography } from '@mui/material' import { Components } from 'react-markdown' -type FrontMatter = { +export type FrontMatter = { title?: string deck?: string date?: string @@ -13,7 +13,10 @@ type ContentPageProps = { src: string } -function parseFrontmatter(text: string): { data: FrontMatter; content: string } { +export function parseFrontmatter(text: string): { + data: FrontMatter + content: string +} { const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/) if (!match) return { data: {}, content: text } @@ -34,7 +37,7 @@ function parseFrontmatter(text: string): { data: FrontMatter; content: string } return { data, content } } -const markdownComponents: Components = { +export const markdownComponents: Components = { h2: ({ children }) => ( {children} @@ -81,6 +84,66 @@ const markdownComponents: Components = { hr: () => , } +type MarkdownPageProps = { + frontmatter: FrontMatter + body: string +} + +export const MarkdownPage: React.FC = ({ + frontmatter, + body, +}) => { + return ( + + + {frontmatter.title && ( + + {frontmatter.title} + + )} + {frontmatter.deck && ( + + {frontmatter.deck} + + )} + {frontmatter.date && ( + + {new Date(frontmatter.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + )} + + + {body} + + + ) +} + export const ContentPage: React.FC = ({ src }) => { const [frontmatter, setFrontmatter] = useState({}) const [body, setBody] = useState('') @@ -106,7 +169,16 @@ export const ContentPage: React.FC = ({ src }) => { if (loading) { return ( - + ) @@ -120,32 +192,5 @@ export const ContentPage: React.FC = ({ src }) => { ) } - return ( - - - {frontmatter.title && ( - - {frontmatter.title} - - )} - {frontmatter.deck && ( - - {frontmatter.deck} - - )} - {frontmatter.date && ( - - {new Date(frontmatter.date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} - - )} - - - {body} - - - ) + return } diff --git a/src/test/components/SearchModal.test.tsx b/src/test/components/SearchModal.test.tsx index 7677a69a..2d0d4fc4 100644 --- a/src/test/components/SearchModal.test.tsx +++ b/src/test/components/SearchModal.test.tsx @@ -4,14 +4,21 @@ import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SearchModal } from '@/components/SearchModal' -const { useAbortableListMock, useSearchHistoryMock } = vi.hoisted(() => ({ +const { + goMock, + searchDocsMock, + useAbortableListMock, + useSearchHistoryMock, +} = vi.hoisted(() => ({ + goMock: vi.fn(), + searchDocsMock: vi.fn(), useAbortableListMock: vi.fn(), useSearchHistoryMock: vi.fn(), })) vi.mock('@refinedev/core', async () => { return { - useGo: () => vi.fn(), + useGo: () => goMock, } }) @@ -23,8 +30,16 @@ vi.mock('@/hooks', () => { } }) +vi.mock('@/utils/docsSearch', () => { + return { + searchDocs: (...args: unknown[]) => searchDocsMock(...args), + } +}) + describe('SearchModal arcade easter eggs', () => { beforeEach(() => { + goMock.mockReset() + searchDocsMock.mockReset() useAbortableListMock.mockReset() useSearchHistoryMock.mockReset() useSearchHistoryMock.mockReturnValue({ @@ -32,6 +47,7 @@ describe('SearchModal arcade easter eggs', () => { add: vi.fn(), clear: vi.fn(), }) + searchDocsMock.mockReturnValue([]) useAbortableListMock.mockReturnValue({ query: { isFetching: false, @@ -116,4 +132,51 @@ describe('SearchModal arcade easter eggs', () => { expect(screen.getByText('Mines left: 10')).toBeTruthy() expect(screen.getByRole('grid', { name: 'Minesweeper game board' })).toBeTruthy() }) + + it('filters the command list as partial shebang commands are typed', async () => { + const user = userEvent.setup() + + render() + + const input = screen.getByRole('textbox', { name: 'Search' }) + + await user.type(input, '!') + expect(screen.getByText('!games')).toBeTruthy() + expect(screen.getByText('!docs')).toBeTruthy() + + await user.type(input, 'd') + expect(screen.queryByText('!games')).toBeNull() + expect(screen.getByText('!docs')).toBeTruthy() + }) + + it('searches local docs results for !docs queries and navigates to the selected page', async () => { + const user = userEvent.setup() + + searchDocsMock.mockReturnValue([ + { + id: 'about', + title: 'About Ocotillo', + path: 'about.md', + slug: 'about', + route: '/about', + content: 'A data management portal', + }, + ]) + + render() + + const input = screen.getByRole('textbox', { name: 'Search' }) + await user.type(input, '!docs content') + + expect(searchDocsMock).toHaveBeenLastCalledWith('content') + expect(screen.getByText('About Ocotillo')).toBeTruthy() + expect(screen.getByText(/about\.md/i)).toBeTruthy() + + const lastCall = useAbortableListMock.mock.calls.at(-1)?.[0] + expect(lastCall?.queryOptions?.enabled).toBe(false) + + await user.keyboard('{Enter}') + + expect(goMock).toHaveBeenCalledWith({ to: '/about', type: 'push' }) + }) }) diff --git a/src/utils/docsSearch.ts b/src/utils/docsSearch.ts new file mode 100644 index 00000000..0adb90f8 --- /dev/null +++ b/src/utils/docsSearch.ts @@ -0,0 +1,83 @@ +import { FrontMatter, parseFrontmatter } from '@/pages/content' + +export type DocEntry = { + id: string + title: string + path: string + slug: string + route: string + content: string + frontmatter: FrontMatter +} + +const docModules = import.meta.glob('../../public/content/**/*.md', { + query: '?raw', + import: 'default', + eager: true, +}) as Record + +const startCase = (value: string) => + value + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) + +const normalizeDocPath = (modulePath: string) => + modulePath.replace(/^.*\/public\/content\//, '') + +const toSlug = (docPath: string) => docPath.replace(/\.md$/i, '') + +export const DOC_ENTRIES: DocEntry[] = Object.entries(docModules) + .map(([modulePath, rawContent]) => { + const path = normalizeDocPath(modulePath) + const slug = toSlug(path) + const parsed = parseFrontmatter(rawContent) + const title = parsed.data.title?.trim() || startCase(slug.split('/').at(-1) || slug) + + return { + id: slug, + title, + path, + slug, + route: `/${slug}`, + content: parsed.content, + frontmatter: { + ...parsed.data, + title, + }, + } + }) + .sort((a, b) => a.title.localeCompare(b.title)) + +const scoreDoc = (doc: DocEntry, normalizedTerm: string) => { + if (!normalizedTerm) return 0 + + const title = doc.title.toLowerCase() + const path = doc.path.toLowerCase() + const content = doc.content.toLowerCase() + + if (title === normalizedTerm) return 500 + if (title.startsWith(normalizedTerm)) return 400 + if (title.includes(normalizedTerm)) return 300 + if (path.includes(normalizedTerm)) return 200 + if (content.includes(normalizedTerm)) return 100 + + return -1 +} + +export const searchDocs = (term: string): DocEntry[] => { + const normalizedTerm = term.trim().toLowerCase() + + if (!normalizedTerm) return DOC_ENTRIES + + return DOC_ENTRIES + .map((doc) => ({ + doc, + score: scoreDoc(doc, normalizedTerm), + })) + .filter((entry) => entry.score >= 0) + .sort((a, b) => b.score - a.score || a.doc.title.localeCompare(b.doc.title)) + .map((entry) => entry.doc) +} + +export const findDocBySlug = (slug: string) => + DOC_ENTRIES.find((doc) => doc.slug === slug) From 1bdd5ece23b9f144d72ce711728ba4b7f1ec4d2c Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 31 Mar 2026 12:35:27 -0500 Subject: [PATCH 3/4] refactor(SearchModal): Broke logic into a hook & results into components --- src/components/Search/CommandResults.tsx | 64 ++ src/components/Search/DefaultResults.tsx | 168 +++++ src/components/Search/DocsResults.tsx | 48 ++ src/components/Search/EmptyState.tsx | 19 + src/components/Search/GameResults.tsx | 62 ++ src/components/Search/RecentSearches.tsx | 61 ++ src/components/Search/index.ts | 6 + src/components/SearchModal.tsx | 887 +++-------------------- src/hooks/index.ts | 1 + src/hooks/useSearchModalState.ts | 256 +++++++ src/test/components/SearchModal.test.tsx | 18 +- src/utils/index.ts | 2 + src/utils/searchModal.ts | 161 ++++ 13 files changed, 951 insertions(+), 802 deletions(-) create mode 100644 src/components/Search/CommandResults.tsx create mode 100644 src/components/Search/DefaultResults.tsx create mode 100644 src/components/Search/DocsResults.tsx create mode 100644 src/components/Search/EmptyState.tsx create mode 100644 src/components/Search/GameResults.tsx create mode 100644 src/components/Search/RecentSearches.tsx create mode 100644 src/components/Search/index.ts create mode 100644 src/hooks/useSearchModalState.ts create mode 100644 src/utils/searchModal.ts diff --git a/src/components/Search/CommandResults.tsx b/src/components/Search/CommandResults.tsx new file mode 100644 index 00000000..bbdd6788 --- /dev/null +++ b/src/components/Search/CommandResults.tsx @@ -0,0 +1,64 @@ +import { Box, Stack, Typography } from '@mui/material' +import { Description } from '@mui/icons-material' +import { COMMANDS } from '@/utils/searchModal' + +type CommandKey = (typeof COMMANDS)[number]['key'] + +type CommandResultsProps = { + commands: readonly { + key: CommandKey + label: string + description: string + }[] + onSelect: (command: CommandKey) => void +} + +export const CommandResults = ({ + commands, + onSelect, +}: CommandResultsProps) => ( + + + + Commands + + + + {commands.map((command) => ( + onSelect(command.key)} + sx={{ + display: 'flex', + alignItems: 'flex-start', + gap: 1.5, + px: 1.5, + py: 1, + borderRadius: 1, + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + + {command.label} + + + {command.description} + + + + ))} + +) diff --git a/src/components/Search/DefaultResults.tsx b/src/components/Search/DefaultResults.tsx new file mode 100644 index 00000000..a225abe3 --- /dev/null +++ b/src/components/Search/DefaultResults.tsx @@ -0,0 +1,168 @@ +import { Box, Chip, Divider, Typography } from '@mui/material' +import { Description, Opacity, Person } from '@mui/icons-material' +import { GroupType } from '@/constants' +import { ContactResult, SearchResult, WellResult } from '@/interfaces/ocotillo' +import { highlight } from '@/utils' + +const TypeIcon = ({ group }: { group: GroupType }) => { + const sx = { fontSize: 18, color: 'text.secondary', flexShrink: 0, mt: '2px' } + + switch (group) { + case GroupType.Wells: + case GroupType.Springs: + return + case GroupType.Contacts: + return + case GroupType.Assets: + return + default: + return null + } +} + +const buildSubtitle = (option: SearchResult): string | null => { + if (option.group === GroupType.Wells || option.group === GroupType.Springs) { + const properties = (option as WellResult).properties + const parts: string[] = [] + + if (properties.owner_name) parts.push(`Owner: ${properties.owner_name}`) + if (properties.county) parts.push(properties.county) + if (properties.site_name) parts.push(properties.site_name) + if (properties.thing_type) parts.push(properties.thing_type) + if (properties.well_depth) + parts.push(`${properties.well_depth.toFixed(0)} ft`) + if (properties.hole_depth) { + parts.push(`hole ${properties.hole_depth.toFixed(0)} ft`) + } + if (properties.well_purposes?.length) + parts.push(...properties.well_purposes) + + return parts.length ? parts.join(' · ') : null + } + + if (option.group === GroupType.Contacts) { + const properties = (option as ContactResult).properties + const parts: string[] = [] + + if (properties.phone?.length) parts.push(properties.phone[0]) + if (properties.address?.length) parts.push(properties.address[0]) + + return parts.length ? parts.join(' · ') : null + } + + return null +} + +const RelatedThings = ({ + things, + query, +}: { + things: { id: number; label: string; thing_type: string }[] + query: string +}) => { + if (!things?.length) return null + + return ( + + {things.slice(0, 4).map((thing) => ( + } + label={highlight(thing.label, query)} + variant="outlined" + sx={{ fontSize: 11 }} + /> + ))} + {things.length > 4 && ( + + )} + + ) +} + +const ResultRow = ({ + option, + query, + onClick, +}: { + option: SearchResult + query: string + onClick: () => void +}) => { + const subtitle = buildSubtitle(option) + const relatedThings = + option.group === GroupType.Contacts + ? (option as ContactResult).properties.things + : option.group === GroupType.Assets + ? (option as any).properties?.things + : null + + return ( + + + + + {highlight(option.label, query)} + + {subtitle && ( + + {subtitle} + + )} + {relatedThings && ( + + )} + + + ) +} + +type DefaultResultsProps = { + grouped: Map + query: string + onSelect: (result: SearchResult) => void +} + +export const DefaultResults = ({ + grouped, + query, + onSelect, +}: DefaultResultsProps) => ( + + {Array.from(grouped.entries()).map(([group, items], groupIndex) => ( + + {groupIndex > 0 && } + {items.map((option, index) => ( + onSelect(option)} + /> + ))} + + ))} + +) diff --git a/src/components/Search/DocsResults.tsx b/src/components/Search/DocsResults.tsx new file mode 100644 index 00000000..670d8f30 --- /dev/null +++ b/src/components/Search/DocsResults.tsx @@ -0,0 +1,48 @@ +import { Box, Typography } from '@mui/material' +import { Description } from '@mui/icons-material' +import { DocEntry } from '@/utils/docsSearch' +import { buildDocExcerpt } from '@/utils/searchModal' +import { highlight } from '@/utils' + +type DocsResultsProps = { + docs: DocEntry[] + query: string + onSelect: (doc: DocEntry) => void +} + +export const DocsResults = ({ docs, query, onSelect }: DocsResultsProps) => ( + + {docs.map((doc) => ( + onSelect(doc)} + sx={{ + display: 'flex', + alignItems: 'flex-start', + gap: 1.5, + px: 1.5, + py: 1, + borderRadius: 1, + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + + {highlight(doc.title, query)} + + + {highlight(buildDocExcerpt(doc, query), query)} + + + + ))} + +) diff --git a/src/components/Search/EmptyState.tsx b/src/components/Search/EmptyState.tsx new file mode 100644 index 00000000..54624bbc --- /dev/null +++ b/src/components/Search/EmptyState.tsx @@ -0,0 +1,19 @@ +import { Typography } from '@mui/material' + +type EmptyStateProps = { + color?: 'error' | 'text.secondary' + message: string +} + +export const EmptyState = ({ + color = 'text.secondary', + message, +}: EmptyStateProps) => ( + + {message} + +) diff --git a/src/components/Search/GameResults.tsx b/src/components/Search/GameResults.tsx new file mode 100644 index 00000000..70ff726b --- /dev/null +++ b/src/components/Search/GameResults.tsx @@ -0,0 +1,62 @@ +import { Box, Stack, Typography } from '@mui/material' +import { ArcadeGame, GAMES } from '@/utils/searchModal' + +type GameResultsProps = { + games: typeof GAMES + term: string + onSelect: (game: ArcadeGame) => void +} + +export const GameResults = ({ games, term, onSelect }: GameResultsProps) => ( + + + + Games + + + + {games.map((game) => ( + onSelect(game.key)} + sx={{ + display: 'flex', + alignItems: 'flex-start', + gap: 1.5, + px: 1.5, + py: 1, + borderRadius: 1, + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + {game.label} + + + {game.description} + + + + ))} + + {games.length === 0 && ( + + No games found for "{term}". + + )} + +) diff --git a/src/components/Search/RecentSearches.tsx b/src/components/Search/RecentSearches.tsx new file mode 100644 index 00000000..fcc59a57 --- /dev/null +++ b/src/components/Search/RecentSearches.tsx @@ -0,0 +1,61 @@ +import { Box, Stack, Typography } from '@mui/material' +import { AccessTime } from '@mui/icons-material' + +type RecentSearchesProps = { + searches: string[] + onClear: () => void + onSelect: (query: string) => void +} + +export const RecentSearches = ({ + searches, + onClear, + onSelect, +}: RecentSearchesProps) => ( + + + + Recent searches + + + Clear history + + + {searches.map((query) => ( + onSelect(query)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1.5, + px: 1.5, + py: 0.75, + cursor: 'pointer', + borderRadius: 1, + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + {query} + + + ))} + +) diff --git a/src/components/Search/index.ts b/src/components/Search/index.ts new file mode 100644 index 00000000..438386d0 --- /dev/null +++ b/src/components/Search/index.ts @@ -0,0 +1,6 @@ +export * from './CommandResults' +export * from './DefaultResults' +export * from './DocsResults' +export * from './EmptyState' +export * from './GameResults' +export * from './RecentSearches' diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 616fe823..31b72ebb 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -1,29 +1,21 @@ -import { useEffect, useMemo, useRef, useState } from 'react' import { - Box, - Chip, Dialog, - Divider, - IconButton, - InputAdornment, + Box, InputBase, - Stack, + InputAdornment, + IconButton, Typography, } from '@mui/material' +import { Clear, Search } from '@mui/icons-material' +import { useSearchModalState } from '@/hooks' import { - AccessTime, - Clear, - Description, - Opacity, - Person, - Search, -} from '@mui/icons-material' -import { useGo } from '@refinedev/core' -import { useDebounce, useAbortableList, useSearchHistory } from '@/hooks' -import { GroupType } from '@/constants' -import { SearchResult, WellResult, ContactResult } from '@/interfaces/ocotillo' -import { DocEntry, searchDocs } from '@/utils/docsSearch' -import { highlight } from '@/utils' + CommandResults, + DefaultResults, + DocsResults, + EmptyState, + GameResults, + RecentSearches, +} from '@/components/Search' import { SnakeGameModal, AsteroidsGameModal, @@ -31,233 +23,7 @@ import { TetrisGameModal, MinesweeperGameModal, } from '@/components/Search/EasterEggsGames' - -type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' | 'minesweeper' - -type SearchMode = - | 'default' - | 'command-root' - | 'games' - | 'docs' - | 'unknown-command' - -type ParsedSearch = - | { mode: 'default'; term: string } - | { mode: 'command-root'; term: string } - | { mode: 'games'; term: string } - | { mode: 'docs'; term: string } - | { mode: 'unknown-command'; command: string; term: string } - -const COMMANDS = [ - { - key: 'games', - label: '!games', - description: 'Browse and launch games', - }, - { - key: 'docs', - label: '!docs', - description: 'Search docs by title or content', - }, -] as const - -const GAMES: { key: ArcadeGame; label: string; description: string }[] = [ - { key: 'snake', label: 'Snake', description: 'Classic snake game' }, - { key: 'asteroids', label: 'Asteroids', description: 'Arcade space shooter' }, - { key: 'racecar', label: 'Race Car', description: 'Driving game' }, - { key: 'tetris', label: 'Tetris', description: 'Block puzzle game' }, - { key: 'minesweeper', label: 'Minesweeper', description: 'Find all mines' }, -] - -const buildDocExcerpt = (doc: DocEntry, query: string) => { - const trimmedQuery = query.trim().toLowerCase() - if (!trimmedQuery) return doc.path - - const normalizedContent = doc.content.toLowerCase() - const matchIndex = normalizedContent.indexOf(trimmedQuery) - if (matchIndex === -1) return doc.path - - const start = Math.max(0, matchIndex - 40) - const end = Math.min(doc.content.length, matchIndex + trimmedQuery.length + 80) - const excerpt = doc.content.slice(start, end).replace(/\s+/g, ' ').trim() - - return `${doc.path} · ${excerpt}${end < doc.content.length ? '...' : ''}` -} - -// ---- type icon mapping ------------------------------------------------ - -const TypeIcon = ({ group }: { group: GroupType }) => { - const sx = { fontSize: 18, color: 'text.secondary', flexShrink: 0, mt: '2px' } - switch (group) { - case GroupType.Wells: - case GroupType.Springs: - return - case GroupType.Contacts: - return - case GroupType.Assets: - return - default: - return null - } -} - -// ---- subtitle builder for each result type ---------------------------- - -const buildSubtitle = (option: SearchResult): string | null => { - if (option.group === GroupType.Wells || option.group === GroupType.Springs) { - const p = (option as WellResult).properties - const parts: string[] = [] - if (p.owner_name) parts.push(`Owner: ${p.owner_name}`) - if (p.county) parts.push(p.county) - if (p.site_name) parts.push(p.site_name) - if (p.thing_type) parts.push(p.thing_type) - if (p.well_depth) parts.push(`${p.well_depth.toFixed(0)} ft`) - if (p.hole_depth) parts.push(`hole ${p.hole_depth.toFixed(0)} ft`) - if (p.well_purposes?.length) parts.push(...p.well_purposes) - return parts.length ? parts.join(' · ') : null - } - - if (option.group === GroupType.Contacts) { - const p = (option as ContactResult).properties - const parts: string[] = [] - if (p.phone?.length) parts.push(p.phone[0]) - if (p.address?.length) parts.push(p.address[0]) - return parts.length ? parts.join(' · ') : null - } - - return null -} - -// ---- related things chips (contacts & assets) ------------------------- - -const RelatedThings = ({ - things, - query, -}: { - things: { id: number; label: string; thing_type: string }[] - query: string -}) => { - if (!things?.length) return null - return ( - - {things.slice(0, 4).map((t) => ( - } - label={highlight(t.label, query)} - variant="outlined" - sx={{ fontSize: 11 }} - /> - ))} - {things.length > 4 && ( - - )} - - ) -} - -// ---- single result row ------------------------------------------------ - -const ResultRow = ({ - option, - query, - onClick, -}: { - option: SearchResult - query: string - onClick: () => void -}) => { - const subtitle = buildSubtitle(option) - const relatedThings = - option.group === GroupType.Contacts - ? (option as ContactResult).properties.things - : option.group === GroupType.Assets - ? (option as any).properties?.things - : null - - return ( - - - - - {highlight(option.label, query)} - - {subtitle && ( - - {subtitle} - - )} - {relatedThings && ( - - )} - - - ) -} - -const DocResultRow = ({ - option, - query, - onClick, -}: { - option: DocEntry - query: string - onClick: () => void -}) => ( - - - - - {highlight(option.title, query)} - - - {highlight(buildDocExcerpt(option, query), query)} - - - -) - -// ---- main modal ------------------------------------------------------- +import { GAMES } from '@/utils' type SearchModalProps = { open: boolean @@ -265,273 +31,13 @@ type SearchModalProps = { } export const SearchModal = ({ open, onClose }: SearchModalProps) => { - const go = useGo() - const history = useSearchHistory() - const inputRef = useRef(null) - - const [query, setQuery] = useState('') - const [recentSearches, setRecentSearches] = useState([]) - const [activeGame, setActiveGame] = useState(null) - const [dismissedGame, setDismissedGame] = useState(null) - const debounced = useDebounce(query, 400) - - const normalizedQuery = query.trim().toLowerCase() - const normalizedDebounced = debounced.trim().toLowerCase() - - const parsed: ParsedSearch = useMemo(() => { - const trimmed = query.trim() - - if (!trimmed.startsWith('!')) { - return { mode: 'default', term: trimmed } - } - - const withoutBang = trimmed.slice(1).trimStart() - - if (!withoutBang.trim()) { - return { mode: 'command-root', term: '' } - } - - const [command, ...rest] = withoutBang.split(/\s+/) - const commandTerm = rest.join(' ').trim() - const normalizedCommand = command.toLowerCase() - - if (normalizedCommand === 'games') { - return { mode: 'games', term: commandTerm } - } - - if (normalizedCommand === 'docs') { - return { mode: 'docs', term: commandTerm } - } - - const partialMatches = COMMANDS.filter((item) => - item.key.startsWith(normalizedCommand) - ) - - if (partialMatches.length > 0 && !commandTerm) { - return { mode: 'command-root', term: normalizedCommand } - } - - return { - mode: 'unknown-command', - command, - term: commandTerm, - } - }, [query]) - - const requestedGame: ArcadeGame | null = useMemo(() => { - const normalizedTerm = - parsed.mode === 'games' - ? parsed.term.trim().toLowerCase() - : normalizedQuery - - if (!normalizedTerm) return null - - const exactMatch = GAMES.find((game) => game.key === normalizedTerm) - return exactMatch?.key ?? null - }, [normalizedQuery, parsed]) - - // Reload history each time modal opens - useEffect(() => { - if (open) { - setQuery('') - setRecentSearches(history.get()) - setDismissedGame(null) - setActiveGame(null) - } - }, [open]) - - useEffect(() => { - if (open) { - inputRef.current?.focus() - } - }, [open]) - - useEffect(() => { - if (!requestedGame) { - setDismissedGame(null) - return - } - - if ( - open && - activeGame !== requestedGame && - dismissedGame !== requestedGame - ) { - setActiveGame(requestedGame) - } - }, [activeGame, dismissedGame, open, requestedGame]) - - const { query: searchQuery, result } = useAbortableList({ - resource: 'search', - dataProviderName: 'ocotillo', - pagination: { pageSize: 100 }, - queryOptions: { - enabled: - open && parsed.mode === 'default' && normalizedDebounced.length >= 1, - staleTime: 120_000, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }, - meta: { params: { q: debounced } }, - }) - - const results: SearchResult[] = useMemo(() => { - if (parsed.mode !== 'default') return [] - if (!query.trim()) return [] - if (searchQuery.isFetching) return [] - if (searchQuery.isError) return [] - - const normalized = - result?.data?.map((r: any) => ({ - label: r.label, - description: r.description, - group: r.group as GroupType, - properties: r.properties, - })) ?? [] - - return dedupeResults(normalized) - }, [parsed.mode, result, query, searchQuery.isFetching, searchQuery.isError]) - - // Group results by type for section headers - const grouped = useMemo(() => { - const map = new Map() - for (const r of results) { - if (!map.has(r.group)) map.set(r.group, []) - map.get(r.group)!.push(r) - } - return map - }, [results]) - - const filteredCommands = useMemo(() => { - if (parsed.mode !== 'command-root') return [] - - const term = parsed.term.trim().toLowerCase() - if (!term) return COMMANDS - - return COMMANDS.filter((command) => command.key.startsWith(term)) - }, [parsed]) - - const filteredGames = useMemo(() => { - if (parsed.mode !== 'games') return [] - - const term = parsed.term.trim().toLowerCase() - if (!term) return GAMES - - return GAMES.filter( - (game) => - game.key.includes(term) || game.label.toLowerCase().includes(term) - ) - }, [parsed]) - - const docsResults = useMemo(() => { - if (parsed.mode !== 'docs') return [] - - return searchDocs(parsed.term) - }, [parsed]) - - const navigateToResult = (option: SearchResult) => { - switch (option.group) { - case GroupType.Wells: - case GroupType.Springs: { - const p = (option as WellResult).properties - const isWaterWell = p.thing_type === 'water well' - go({ - to: { - resource: isWaterWell - ? 'ocotillo.thing-well' - : 'ocotillo.thing-spring', - action: 'show', - id: p.id, - }, - }) - break - } - case GroupType.Contacts: - go({ - to: { - resource: 'ocotillo.contact', - action: 'show', - id: (option as ContactResult).properties.id, - }, - }) - break - case GroupType.Assets: - go({ - to: { - resource: 'ocotillo.asset', - action: 'show', - id: (option as any).properties.id, - }, - }) - break - } - } - - const handleCommandClick = (command: 'games' | 'docs') => { - setQuery(`!${command} `) - inputRef.current?.focus() - } - - const handleDocSelect = (doc: DocEntry) => { - go({ to: doc.route, type: 'push' }) - handleClose() - } - - const handleGameSelect = (game: ArcadeGame) => { - setActiveGame(game) - } - - const handleSelect = (option: SearchResult) => { - navigateToResult(option) - handleClose() - } - - const handleRecentClick = (q: string) => { - setQuery(q) - inputRef.current?.focus() - } - - const handleClearHistory = () => { - history.clear() - setRecentSearches([]) - } - - const handleClose = () => { - if (query.trim()) history.add(query) - setQuery('') - setActiveGame(null) - setDismissedGame(null) - onClose() - } - - const handleGameClose = () => { - setDismissedGame(activeGame) - setActiveGame(null) - } - - const showRecent = !query.trim() && recentSearches.length > 0 - - const showDefaultEmpty = - parsed.mode === 'default' && - query.trim() && - !searchQuery.isFetching && - !searchQuery.isError && - results.length === 0 - - const showDocsEmpty = - parsed.mode === 'docs' && docsResults.length === 0 - - const showError = - parsed.mode === 'default' && - query.trim() && - !searchQuery.isFetching && - searchQuery.isError + const state = useSearchModalState({ open, onClose }) return ( <> { }, }} > - {/* Search input row */} { > setQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { - handleClose() + inputRef={state.inputRef} + value={state.query} + onChange={(event) => state.setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') { + state.handleClose() return } - if (e.key === 'Enter') { - if (parsed.mode === 'command-root' && filteredCommands.length > 0) { - handleCommandClick(filteredCommands[0].key) - return - } - - if (parsed.mode === 'games' && filteredGames.length > 0) { - handleGameSelect(filteredGames[0].key) - return - } - - if (parsed.mode === 'docs' && docsResults.length > 0) { - handleDocSelect(docsResults[0]) - return - } - - if (parsed.mode === 'default' && results.length > 0) { - handleSelect(results[0]) - } + if (event.key === 'Enter') { + state.handleEnter() } }} placeholder='Search or type "!" for commands' @@ -597,11 +85,11 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { sx={{ fontSize: 15 }} inputProps={{ 'aria-label': 'Search' }} endAdornment={ - query ? ( + state.query ? ( setQuery('')} + onClick={() => state.setQuery('')} edge="end" > @@ -612,111 +100,23 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { /> - {/* Results area */} - {parsed.mode === 'command-root' && ( - - - - Commands - - - - {filteredCommands.map((command: any) => ( - handleCommandClick(command.key)} - sx={{ - display: 'flex', - alignItems: 'flex-start', - gap: 1.5, - px: 1.5, - py: 1, - borderRadius: 1, - cursor: 'pointer', - '&:hover': { bgcolor: 'action.hover' }, - }} - > - - - - {command.label} - - - {command.description} - - - - ))} - + {state.parsed.mode === 'command-root' && ( + )} - {parsed.mode === 'games' && !requestedGame && ( - - - - Games - - - - {filteredGames.map((game) => ( - handleGameSelect(game.key)} - sx={{ - display: 'flex', - alignItems: 'flex-start', - gap: 1.5, - px: 1.5, - py: 1, - borderRadius: 1, - cursor: 'pointer', - '&:hover': { bgcolor: 'action.hover' }, - }} - > - - - {game.label} - - - {game.description} - - - - ))} - - {filteredGames.length === 0 && ( - - No games found for "{parsed.term}". - - )} - + {state.parsed.mode === 'games' && !state.requestedGame && ( + )} - {/* Loading indicator */} - {searchQuery.isFetching && ( + {state.searchQuery.isFetching && ( { )} - {requestedGame && ( - - Press Enter to open{' '} - {GAMES.find((g) => g.key === requestedGame)?.label ?? - requestedGame} - . - + {state.requestedGame && ( + game.key === state.requestedGame)?.label ?? + state.requestedGame + }.`} + /> )} - {/* Recent searches */} - {showRecent && ( - - - - Recent searches - - - Clear history - - - {recentSearches.map((q) => ( - handleRecentClick(q)} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.5, - px: 1.5, - py: 0.75, - cursor: 'pointer', - borderRadius: 1, - '&:hover': { bgcolor: 'action.hover' }, - }} - > - - - {q} - - - ))} - + {state.showRecent && ( + )} - {/* Empty state */} - {showDefaultEmpty && ( - - No results for "{query}". Try a well ID, site name, or contact - name. - + {state.showDefaultEmpty && ( + )} - {showDocsEmpty && ( - - No docs found for "{parsed.term}". - + {state.showDocsEmpty && ( + )} - {/* Error state */} - {showError && ( - - Search failed. Please try again. - + message="Search failed. Please try again." + /> )} - {parsed.mode === 'unknown-command' && ( - - Unknown command "!{parsed.command}". Try !games or !docs. - + {state.parsed.mode === 'unknown-command' && ( + )} - {parsed.mode === 'docs' && docsResults.length > 0 && ( - - {docsResults.map((doc) => ( - handleDocSelect(doc)} - /> - ))} - + {state.parsed.mode === 'docs' && state.docsResults.length > 0 && ( + )} - {/* Grouped results */} - {!searchQuery.isFetching && - !requestedGame && - parsed.mode === 'default' && - grouped.size > 0 && ( - - {Array.from(grouped.entries()).map( - ([group, items], groupIndex) => ( - - {groupIndex > 0 && } - {items.map((option, i) => ( - handleSelect(option)} - /> - ))} - - ) - )} - - )} + {!state.searchQuery.isFetching && + !state.requestedGame && + state.parsed.mode === 'default' && + state.grouped.size > 0 && ( + + )} - + ) } -// ---- deduplication (same logic as SearchBar) -------------------------- - -const dedupeResults = (items: SearchResult[]): SearchResult[] => { - const seen = new Set() - return items.filter((item) => { - if (item.group === GroupType.Messages) return true - const id = (item as any).properties?.id - if (!id) return true - const key = `${item.group}-${id}` - if (seen.has(key)) return false - seen.add(key) - return true - }) -} - export default SearchModal diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c93e62dc..e90d7578 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -17,3 +17,4 @@ export * from './useSensorDeploymentRows' export * from './useThingLayers' export * from './useUSGSSiteInfo' export * from './useViewportBbox' +export * from './useSearchModalState' diff --git a/src/hooks/useSearchModalState.ts b/src/hooks/useSearchModalState.ts new file mode 100644 index 00000000..ee54f4fa --- /dev/null +++ b/src/hooks/useSearchModalState.ts @@ -0,0 +1,256 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { useGo } from '@refinedev/core' +import { GroupType } from '@/constants' +import { useAbortableList } from './useAbortableList' +import { useDebounce } from './useDebounce' +import { useSearchHistory } from './useSearchHistory' +import { ContactResult, SearchResult, WellResult } from '@/interfaces/ocotillo' +import { DocEntry, searchDocs } from '@/utils/docsSearch' +import { + ArcadeGame, + dedupeResults, + filterCommands, + filterGames, + getRequestedGame, + groupSearchResults, + parseSearchQuery, +} from '@/utils/searchModal' + +type UseSearchModalStateParams = { + open: boolean + onClose: () => void +} + +export const useSearchModalState = ({ + open, + onClose, +}: UseSearchModalStateParams) => { + const go = useGo() + const history = useSearchHistory() + const inputRef = useRef(null) + + const [query, setQuery] = useState('') + const [recentSearches, setRecentSearches] = useState([]) + const [activeGame, setActiveGame] = useState(null) + const [dismissedGame, setDismissedGame] = useState(null) + const debounced = useDebounce(query, 400) + + const normalizedQuery = query.trim().toLowerCase() + const normalizedDebounced = debounced.trim().toLowerCase() + + const parsed = useMemo(() => parseSearchQuery(query), [query]) + + const requestedGame = useMemo( + () => getRequestedGame(parsed, normalizedQuery), + [normalizedQuery, parsed] + ) + + useEffect(() => { + if (open) { + setQuery('') + setRecentSearches(history.get()) + setDismissedGame(null) + setActiveGame(null) + } + }, [open]) + + useEffect(() => { + if (open) { + inputRef.current?.focus() + } + }, [open]) + + useEffect(() => { + if (!requestedGame) { + setDismissedGame(null) + return + } + + if ( + open && + activeGame !== requestedGame && + dismissedGame !== requestedGame + ) { + setActiveGame(requestedGame) + } + }, [activeGame, dismissedGame, open, requestedGame]) + + const { query: searchQuery, result } = useAbortableList({ + resource: 'search', + dataProviderName: 'ocotillo', + pagination: { pageSize: 100 }, + queryOptions: { + enabled: + open && parsed.mode === 'default' && normalizedDebounced.length >= 1, + staleTime: 120_000, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }, + meta: { params: { q: debounced } }, + }) + + const results: SearchResult[] = useMemo(() => { + if (parsed.mode !== 'default') return [] + if (!query.trim()) return [] + if (searchQuery.isFetching) return [] + if (searchQuery.isError) return [] + + const normalized = + result?.data?.map((r: any) => ({ + label: r.label, + description: r.description, + group: r.group as GroupType, + properties: r.properties, + })) ?? [] + + return dedupeResults(normalized) + }, [parsed.mode, query, result, searchQuery.isError, searchQuery.isFetching]) + + const grouped = useMemo(() => groupSearchResults(results), [results]) + const filteredCommands = useMemo(() => filterCommands(parsed), [parsed]) + const filteredGames = useMemo(() => filterGames(parsed), [parsed]) + const docsResults = useMemo(() => { + if (parsed.mode !== 'docs') return [] + + return searchDocs(parsed.term) + }, [parsed]) + + const navigateToResult = (option: SearchResult) => { + switch (option.group) { + case GroupType.Wells: + case GroupType.Springs: { + const properties = (option as WellResult).properties + const isWaterWell = properties.thing_type === 'water well' + go({ + to: { + resource: isWaterWell + ? 'ocotillo.thing-well' + : 'ocotillo.thing-spring', + action: 'show', + id: properties.id, + }, + }) + break + } + case GroupType.Contacts: + go({ + to: { + resource: 'ocotillo.contact', + action: 'show', + id: (option as ContactResult).properties.id, + }, + }) + break + case GroupType.Assets: + go({ + to: { + resource: 'ocotillo.asset', + action: 'show', + id: (option as any).properties.id, + }, + }) + break + } + } + + const handleClose = () => { + if (query.trim()) history.add(query) + setQuery('') + setActiveGame(null) + setDismissedGame(null) + onClose() + } + + const handleCommandSelect = (command: 'games' | 'docs') => { + setQuery(`!${command} `) + inputRef.current?.focus() + } + + const handleDocSelect = (doc: DocEntry) => { + go({ to: doc.route, type: 'push' }) + handleClose() + } + + const handleGameSelect = (game: ArcadeGame) => { + setActiveGame(game) + } + + const handleResultSelect = (option: SearchResult) => { + navigateToResult(option) + handleClose() + } + + const handleRecentClick = (value: string) => { + setQuery(value) + inputRef.current?.focus() + } + + const handleClearHistory = () => { + history.clear() + setRecentSearches([]) + } + + const handleGameClose = () => { + setDismissedGame(activeGame) + setActiveGame(null) + } + + const handleEnter = () => { + if (parsed.mode === 'command-root' && filteredCommands.length > 0) { + handleCommandSelect(filteredCommands[0].key) + return + } + + if (parsed.mode === 'games' && filteredGames.length > 0) { + handleGameSelect(filteredGames[0].key) + return + } + + if (parsed.mode === 'docs' && docsResults.length > 0) { + handleDocSelect(docsResults[0]) + return + } + + if (parsed.mode === 'default' && results.length > 0) { + handleResultSelect(results[0]) + } + } + + return { + activeGame, + docsResults, + filteredCommands, + filteredGames, + grouped, + handleClearHistory, + handleClose, + handleCommandSelect, + handleDocSelect, + handleEnter, + handleGameClose, + handleGameSelect, + handleRecentClick, + handleResultSelect, + inputRef, + parsed, + query, + recentSearches, + requestedGame, + results, + searchQuery, + setQuery, + showDefaultEmpty: + parsed.mode === 'default' && + query.trim() && + !searchQuery.isFetching && + !searchQuery.isError && + results.length === 0, + showDocsEmpty: parsed.mode === 'docs' && docsResults.length === 0, + showError: + parsed.mode === 'default' && + query.trim() && + !searchQuery.isFetching && + searchQuery.isError, + showRecent: !query.trim() && recentSearches.length > 0, + } +} diff --git a/src/test/components/SearchModal.test.tsx b/src/test/components/SearchModal.test.tsx index 2d0d4fc4..1d3b5335 100644 --- a/src/test/components/SearchModal.test.tsx +++ b/src/test/components/SearchModal.test.tsx @@ -22,13 +22,17 @@ vi.mock('@refinedev/core', async () => { } }) -vi.mock('@/hooks', () => { - return { - useDebounce: (value: string) => value, - useAbortableList: (...args: unknown[]) => useAbortableListMock(...args), - useSearchHistory: () => useSearchHistoryMock(), - } -}) +vi.mock('@/hooks/useDebounce', () => ({ + useDebounce: (value: string) => value, +})) + +vi.mock('@/hooks/useAbortableList', () => ({ + useAbortableList: (...args: unknown[]) => useAbortableListMock(...args), +})) + +vi.mock('@/hooks/useSearchHistory', () => ({ + useSearchHistory: () => useSearchHistoryMock(), +})) vi.mock('@/utils/docsSearch', () => { return { diff --git a/src/utils/index.ts b/src/utils/index.ts index a804db82..7abe4f11 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -25,3 +25,5 @@ export * from './Transform' export * from './UpdateMapView' export * from './UtmToLonLat' export * from './WellBatchExport' +export * from './docsSearch' +export * from './searchModal' diff --git a/src/utils/searchModal.ts b/src/utils/searchModal.ts new file mode 100644 index 00000000..a296a409 --- /dev/null +++ b/src/utils/searchModal.ts @@ -0,0 +1,161 @@ +import { GroupType } from '@/constants' +import { SearchResult } from '@/interfaces/ocotillo' +import { DocEntry } from '@/utils/docsSearch' + +export type ArcadeGame = + | 'snake' + | 'asteroids' + | 'racecar' + | 'tetris' + | 'minesweeper' + +export type SearchMode = + | 'default' + | 'command-root' + | 'games' + | 'docs' + | 'unknown-command' + +export type ParsedSearch = + | { mode: 'default'; term: string } + | { mode: 'command-root'; term: string } + | { mode: 'games'; term: string } + | { mode: 'docs'; term: string } + | { mode: 'unknown-command'; command: string; term: string } + +export const COMMANDS = [ + { + key: 'games', + label: '!games', + description: 'Browse and launch games', + }, + { + key: 'docs', + label: '!docs', + description: 'Search docs by title or content', + }, +] as const + +export const GAMES: { + key: ArcadeGame + label: string + description: string +}[] = [ + { key: 'snake', label: 'Snake', description: 'Classic snake game' }, + { key: 'asteroids', label: 'Asteroids', description: 'Arcade space shooter' }, + { key: 'racecar', label: 'Race Car', description: 'Driving game' }, + { key: 'tetris', label: 'Tetris', description: 'Block puzzle game' }, + { key: 'minesweeper', label: 'Minesweeper', description: 'Find all mines' }, +] + +export const parseSearchQuery = (query: string): ParsedSearch => { + const trimmed = query.trim() + + if (!trimmed.startsWith('!')) { + return { mode: 'default', term: trimmed } + } + + const withoutBang = trimmed.slice(1).trimStart() + + if (!withoutBang.trim()) { + return { mode: 'command-root', term: '' } + } + + const [command, ...rest] = withoutBang.split(/\s+/) + const commandTerm = rest.join(' ').trim() + const normalizedCommand = command.toLowerCase() + + if (normalizedCommand === 'games') { + return { mode: 'games', term: commandTerm } + } + + if (normalizedCommand === 'docs') { + return { mode: 'docs', term: commandTerm } + } + + const partialMatches = COMMANDS.filter((item) => + item.key.startsWith(normalizedCommand) + ) + + if (partialMatches.length > 0 && !commandTerm) { + return { mode: 'command-root', term: normalizedCommand } + } + + return { + mode: 'unknown-command', + command, + term: commandTerm, + } +} + +export const filterCommands = (parsed: ParsedSearch) => { + if (parsed.mode !== 'command-root') return [] + + const term = parsed.term.trim().toLowerCase() + if (!term) return COMMANDS + + return COMMANDS.filter((command) => command.key.startsWith(term)) +} + +export const filterGames = (parsed: ParsedSearch) => { + if (parsed.mode !== 'games') return [] + + const term = parsed.term.trim().toLowerCase() + if (!term) return GAMES + + return GAMES.filter( + (game) => game.key.includes(term) || game.label.toLowerCase().includes(term) + ) +} + +export const getRequestedGame = ( + parsed: ParsedSearch, + normalizedQuery: string +): ArcadeGame | null => { + const normalizedTerm = + parsed.mode === 'games' ? parsed.term.trim().toLowerCase() : normalizedQuery + + if (!normalizedTerm) return null + + const exactMatch = GAMES.find((game) => game.key === normalizedTerm) + return exactMatch?.key ?? null +} + +export const buildDocExcerpt = (doc: DocEntry, query: string) => { + const trimmedQuery = query.trim().toLowerCase() + if (!trimmedQuery) return doc.path + + const normalizedContent = doc.content.toLowerCase() + const matchIndex = normalizedContent.indexOf(trimmedQuery) + if (matchIndex === -1) return doc.path + + const start = Math.max(0, matchIndex - 40) + const end = Math.min(doc.content.length, matchIndex + trimmedQuery.length + 80) + const excerpt = doc.content.slice(start, end).replace(/\s+/g, ' ').trim() + + return `${doc.path} · ${excerpt}${end < doc.content.length ? '...' : ''}` +} + +export const dedupeResults = (items: SearchResult[]): SearchResult[] => { + const seen = new Set() + return items.filter((item) => { + if (item.group === GroupType.Messages) return true + const id = (item as any).properties?.id + if (!id) return true + const key = `${item.group}-${id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +export const groupSearchResults = (results: SearchResult[]) => { + const map = new Map() + + for (const result of results) { + if (!map.has(result.group)) map.set(result.group, []) + map.get(result.group)!.push(result) + } + + return map +} From 5a42dbf3fdce6a509f38ea21cd4de26032f6cefd Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 31 Mar 2026 13:22:24 -0500 Subject: [PATCH 4/4] chore(SearchModal): Remove shebang prompt to keep easter egg hidden --- src/components/SearchModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 31b72ebb..df722ec4 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -80,7 +80,7 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { state.handleEnter() } }} - placeholder='Search or type "!" for commands' + placeholder="Search" fullWidth sx={{ fontSize: 15 }} inputProps={{ 'aria-label': 'Search' }}