|
| 1 | +/** |
| 2 | + * Copyright (c) 2025, RTE (http://www.rte-france.com) |
| 3 | + * This Source Code Form is subject to the terms of the Mozilla Public |
| 4 | + * License, v. 2.0. If a copy of the MPL was not distributed with this |
| 5 | + * file, You can obtain one at http://mozilla.org/MPL/2.0/. |
| 6 | + */ |
| 7 | +import React, { useCallback, useMemo, useRef, useState } from 'react'; |
| 8 | +import { Box, Button, TextField } from '@mui/material'; |
| 9 | +import FindReplaceIcon from '@mui/icons-material/FindReplace'; |
| 10 | +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; |
| 11 | +import { useIntl } from 'react-intl'; |
| 12 | +import { useFormContext } from 'react-hook-form'; |
| 13 | +import { useButtonWithTooltip } from '../../../../utils/inputs/input-hooks'; |
| 14 | +import { COLUMNS_MODEL, COLUMN_FORMULA } from './spreadsheet-model-global-editor.utils'; |
| 15 | +import { QuickSearch } from 'components/report-viewer/QuickSearch'; |
| 16 | +import { useFormulaSearch } from './formula-search-context'; |
| 17 | + |
| 18 | +export default function FormulaSearchReplace() { |
| 19 | + const intl = useIntl(); |
| 20 | + const { getValues, setValue, setFocus } = useFormContext(); |
| 21 | + const { searchTerm, setSearchTerm, searchResults, setSearchResults, currentResultIndex, setCurrentResultIndex } = |
| 22 | + useFormulaSearch(); |
| 23 | + |
| 24 | + const [open, setOpen] = useState(false); |
| 25 | + const [replace, setReplace] = useState(''); |
| 26 | + |
| 27 | + const handleOpen = useCallback(() => { |
| 28 | + setOpen(true); |
| 29 | + setCurrentResultIndex(-1); |
| 30 | + }, [setCurrentResultIndex]); |
| 31 | + |
| 32 | + const handleClose = useCallback(() => { |
| 33 | + setOpen(false); |
| 34 | + setReplace(''); |
| 35 | + setSearchTerm(''); |
| 36 | + setSearchResults([]); |
| 37 | + setCurrentResultIndex(-1); |
| 38 | + }, [setCurrentResultIndex, setReplace, setSearchResults, setSearchTerm]); |
| 39 | + |
| 40 | + const escapeRegExp = useCallback((value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), []); |
| 41 | + |
| 42 | + const escapedSearchTerm = useMemo(() => { |
| 43 | + if (!searchTerm) { |
| 44 | + return ''; |
| 45 | + } |
| 46 | + |
| 47 | + return escapeRegExp(searchTerm); |
| 48 | + }, [escapeRegExp, searchTerm]); |
| 49 | + |
| 50 | + const searchRegex = useMemo(() => { |
| 51 | + if (!escapedSearchTerm) { |
| 52 | + return null; |
| 53 | + } |
| 54 | + |
| 55 | + return new RegExp(escapedSearchTerm, 'i'); |
| 56 | + }, [escapedSearchTerm]); |
| 57 | + |
| 58 | + const replaceInFormula = useCallback( |
| 59 | + (formula: string) => { |
| 60 | + if (!escapedSearchTerm) { |
| 61 | + return formula; |
| 62 | + } |
| 63 | + |
| 64 | + const regex = new RegExp(escapedSearchTerm, 'gi'); |
| 65 | + return formula.replace(regex, replace); |
| 66 | + }, |
| 67 | + [escapedSearchTerm, replace] |
| 68 | + ); |
| 69 | + |
| 70 | + const focusFormula = useCallback( |
| 71 | + (rowIndex: number) => { |
| 72 | + const columns = getValues(COLUMNS_MODEL) as any[]; |
| 73 | + const formula = columns[rowIndex][COLUMN_FORMULA] || ''; |
| 74 | + const fieldName = `${COLUMNS_MODEL}[${rowIndex}].${COLUMN_FORMULA}`; |
| 75 | + const previouslyFocused = document.activeElement as HTMLElement | null; |
| 76 | + |
| 77 | + setFocus(fieldName); |
| 78 | + const input = document.querySelector<HTMLInputElement | HTMLTextAreaElement>( |
| 79 | + `textarea[name="${fieldName}"], input[name="${fieldName}"]` |
| 80 | + ); |
| 81 | + if (input && searchTerm) { |
| 82 | + const index = formula.toLowerCase().indexOf(searchTerm.toLowerCase()); |
| 83 | + if (index >= 0) { |
| 84 | + input.setSelectionRange(index, index + searchTerm.length); |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + if (previouslyFocused && previouslyFocused !== input) { |
| 89 | + previouslyFocused.focus({ preventScroll: true }); |
| 90 | + } |
| 91 | + }, |
| 92 | + [getValues, searchTerm, setFocus] |
| 93 | + ); |
| 94 | + |
| 95 | + const performSearch = useCallback( |
| 96 | + (term: string) => { |
| 97 | + setSearchTerm(term); |
| 98 | + if (!term) { |
| 99 | + setSearchResults([]); |
| 100 | + setCurrentResultIndex(-1); |
| 101 | + return; |
| 102 | + } |
| 103 | + const columns = getValues(COLUMNS_MODEL) as any[]; |
| 104 | + const matches: number[] = []; |
| 105 | + columns.forEach((column, idx) => { |
| 106 | + const formula = column[COLUMN_FORMULA] || ''; |
| 107 | + if (formula.toLowerCase().includes(term.toLowerCase())) { |
| 108 | + matches.push(idx); |
| 109 | + } |
| 110 | + }); |
| 111 | + setSearchResults(matches); |
| 112 | + setCurrentResultIndex(matches.length > 0 ? 0 : -1); |
| 113 | + if (matches.length > 0) { |
| 114 | + focusFormula(matches[0]); |
| 115 | + } |
| 116 | + }, |
| 117 | + [focusFormula, getValues, setCurrentResultIndex, setSearchResults, setSearchTerm] |
| 118 | + ); |
| 119 | + |
| 120 | + const handleNavigate = useCallback( |
| 121 | + (direction: 'next' | 'previous') => { |
| 122 | + if (searchResults.length === 0) { |
| 123 | + return; |
| 124 | + } |
| 125 | + let newIndex = currentResultIndex + (direction === 'next' ? 1 : -1); |
| 126 | + if (newIndex >= searchResults.length) { |
| 127 | + newIndex = 0; |
| 128 | + } |
| 129 | + if (newIndex < 0) { |
| 130 | + newIndex = searchResults.length - 1; |
| 131 | + } |
| 132 | + setCurrentResultIndex(newIndex); |
| 133 | + focusFormula(searchResults[newIndex]); |
| 134 | + }, |
| 135 | + [currentResultIndex, focusFormula, searchResults, setCurrentResultIndex] |
| 136 | + ); |
| 137 | + |
| 138 | + const handleReplaceNext = () => { |
| 139 | + if (!searchTerm || !replace || searchResults.length === 0 || currentResultIndex < 0) { |
| 140 | + return; |
| 141 | + } |
| 142 | + const rowIndex = searchResults[currentResultIndex]; |
| 143 | + const columns = getValues(COLUMNS_MODEL) as any[]; |
| 144 | + const formula = columns[rowIndex][COLUMN_FORMULA] || ''; |
| 145 | + const newFormula = replaceInFormula(formula); |
| 146 | + setValue(`${COLUMNS_MODEL}[${rowIndex}].${COLUMN_FORMULA}`, newFormula, { shouldDirty: true }); |
| 147 | + performSearch(searchTerm); |
| 148 | + }; |
| 149 | + |
| 150 | + const handleReplaceAll = () => { |
| 151 | + if (!searchTerm || !replace) { |
| 152 | + return; |
| 153 | + } |
| 154 | + const columns = getValues(COLUMNS_MODEL) as any[]; |
| 155 | + columns.forEach((column, idx) => { |
| 156 | + const formula = column[COLUMN_FORMULA] || ''; |
| 157 | + if (searchRegex?.test(formula)) { |
| 158 | + const newFormula = replaceInFormula(formula); |
| 159 | + setValue(`${COLUMNS_MODEL}[${idx}].${COLUMN_FORMULA}`, newFormula, { shouldDirty: true }); |
| 160 | + } |
| 161 | + }); |
| 162 | + performSearch(searchTerm); |
| 163 | + }; |
| 164 | + |
| 165 | + const searchReplaceButton = useButtonWithTooltip({ |
| 166 | + handleClick: handleOpen, |
| 167 | + label: 'spreadsheet/global-model-edition/search_replace_button', |
| 168 | + icon: <FindReplaceIcon />, |
| 169 | + }); |
| 170 | + |
| 171 | + const closeSearchButton = useButtonWithTooltip({ |
| 172 | + handleClick: handleClose, |
| 173 | + label: 'spreadsheet/global-model-edition/hide_search_button', |
| 174 | + icon: <ChevronLeftIcon />, |
| 175 | + }); |
| 176 | + const inputRef = useRef<HTMLDivElement>(null); |
| 177 | + |
| 178 | + return ( |
| 179 | + <Box sx={{ display: 'flex', justifyContent: 'flex-start', py: 1 }}> |
| 180 | + {open ? ( |
| 181 | + <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}> |
| 182 | + <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}> |
| 183 | + <QuickSearch |
| 184 | + currentResultIndex={currentResultIndex} |
| 185 | + onSearch={performSearch} |
| 186 | + onNavigate={handleNavigate} |
| 187 | + resultCount={searchResults.length} |
| 188 | + resetSearch={() => performSearch('')} |
| 189 | + placeholder="spreadsheet/global-model-edition/search" |
| 190 | + sx={{ width: 280, maxWidth: 280 }} |
| 191 | + inputRef={inputRef} |
| 192 | + /> |
| 193 | + <Box sx={{ display: 'flex', gap: 1 }}> |
| 194 | + <TextField |
| 195 | + size="small" |
| 196 | + sx={{ width: 280 }} |
| 197 | + placeholder={intl.formatMessage({ id: 'spreadsheet/global-model-edition/replace' })} |
| 198 | + value={replace} |
| 199 | + onChange={(e) => setReplace(e.target.value)} |
| 200 | + /> |
| 201 | + <Button onClick={handleReplaceNext} disabled={!searchTerm || !replace}> |
| 202 | + {intl.formatMessage({ id: 'spreadsheet/global-model-edition/replace' })} |
| 203 | + </Button> |
| 204 | + <Button onClick={handleReplaceAll} disabled={!searchTerm || !replace}> |
| 205 | + {intl.formatMessage({ id: 'spreadsheet/global-model-edition/replace_all' })} |
| 206 | + </Button> |
| 207 | + </Box> |
| 208 | + </Box> |
| 209 | + <Box sx={{ display: 'flex', alignItems: 'flex-start' }}>{closeSearchButton}</Box> |
| 210 | + </Box> |
| 211 | + ) : ( |
| 212 | + searchReplaceButton |
| 213 | + )} |
| 214 | + </Box> |
| 215 | + ); |
| 216 | +} |
0 commit comments