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 0d611fe0..df722ec4 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -1,28 +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 { highlight } from '@/utils' + CommandResults, + DefaultResults, + DocsResults, + EmptyState, + GameResults, + RecentSearches, +} from '@/components/Search' import { SnakeGameModal, AsteroidsGameModal, @@ -30,134 +23,7 @@ import { TetrisGameModal, MinesweeperGameModal, } from '@/components/Search/EasterEggsGames' - -type ArcadeGame = 'snake' | 'asteroids' | 'racecar' | 'tetris' | 'minesweeper' - -// ---- 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 && ( - - )} - - - ) -} - -// ---- main modal ------------------------------------------------------- +import { GAMES } from '@/utils' type SearchModalProps = { open: boolean @@ -165,182 +31,29 @@ 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 requestedGame = - normalizedQuery === 'snake' - ? 'snake' - : normalizedQuery === 'asteroids' - ? 'asteroids' - : normalizedQuery === 'racecar' - ? 'racecar' - : normalizedQuery === 'tetris' - ? 'tetris' - : normalizedQuery === 'minesweeper' - ? 'minesweeper' - : null - - // 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 && - normalizedDebounced.length >= 1 && - normalizedDebounced !== 'snake' && - normalizedDebounced !== 'asteroids' && - normalizedDebounced !== 'racecar' && - normalizedDebounced !== 'tetris' && - normalizedDebounced !== 'minesweeper', - staleTime: 120_000, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - }, - meta: { params: { q: debounced } }, - }) - - const results: SearchResult[] = useMemo(() => { - 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) - }, [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 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 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 showEmpty = - query.trim() && - !requestedGame && - !searchQuery.isFetching && - !searchQuery.isError && - results.length === 0 - const showError = query.trim() && !requestedGame && !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 (event.key === 'Enter') { + state.handleEnter() + } + }} placeholder="Search" fullWidth sx={{ fontSize: 15 }} inputProps={{ 'aria-label': 'Search' }} endAdornment={ - query ? ( + state.query ? ( - setQuery('')} edge="end"> + state.setQuery('')} + edge="end" + > @@ -374,119 +100,114 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { /> - {/* Results area */} - + {state.parsed.mode === 'command-root' && ( + + )} + + {state.parsed.mode === 'games' && !state.requestedGame && ( + + )} - {/* Loading indicator */} - {searchQuery.isFetching && ( - + + {state.searchQuery.isFetching && ( + Searching... )} - {requestedGame && ( - - Opening {requestedGame === 'snake' ? 'Snake' : requestedGame === 'asteroids' ? 'Asteroids' : requestedGame === 'racecar' ? 'Race Car' : requestedGame === 'tetris' ? 'Tetris' : 'Minesweeper'}... - + {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 */} - {showEmpty && ( - - No results for "{query}". Try a well ID, site name, or contact name. - + {state.showDefaultEmpty && ( + )} - {/* Error state */} - {showError && ( - - Search failed. Please try again. - + {state.showDocsEmpty && ( + + )} + + {state.showError && ( + + )} + + {state.parsed.mode === 'unknown-command' && ( + )} - {/* Grouped results */} - {!searchQuery.isFetching && !requestedGame && grouped.size > 0 && ( - - {Array.from(grouped.entries()).map(([group, items], groupIndex) => ( - - {groupIndex > 0 && } - {items.map((option, i) => ( - handleSelect(option)} - /> - ))} - - ))} - + {state.parsed.mode === 'docs' && state.docsResults.length > 0 && ( + )} + {!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/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..1d3b5335 100644 --- a/src/test/components/SearchModal.test.tsx +++ b/src/test/components/SearchModal.test.tsx @@ -4,27 +4,46 @@ 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, } }) -vi.mock('@/hooks', () => { +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 { - useDebounce: (value: string) => value, - useAbortableList: (...args: unknown[]) => useAbortableListMock(...args), - useSearchHistory: () => useSearchHistoryMock(), + searchDocs: (...args: unknown[]) => searchDocsMock(...args), } }) describe('SearchModal arcade easter eggs', () => { beforeEach(() => { + goMock.mockReset() + searchDocsMock.mockReset() useAbortableListMock.mockReset() useSearchHistoryMock.mockReset() useSearchHistoryMock.mockReturnValue({ @@ -32,6 +51,7 @@ describe('SearchModal arcade easter eggs', () => { add: vi.fn(), clear: vi.fn(), }) + searchDocsMock.mockReturnValue([]) useAbortableListMock.mockReturnValue({ query: { isFetching: false, @@ -116,4 +136,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) 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 +}