Skip to content

Commit 9091f2c

Browse files
New feature in model edition: search and replace text in formula (#3283)
* working version of search and replace in formulas Signed-off-by: benrejebmoh <[email protected]> * working version but highlight still not good Signed-off-by: benrejebmoh <[email protected]> * fix highlighting in search Signed-off-by: benrejebmoh <[email protected]> * search button at the left of the screen + chevron icon to hide the search/replace Signed-off-by: benrejebmoh <[email protected]> * add licence header to new file Signed-off-by: benrejebmoh <[email protected]> * remove unnecessary check Signed-off-by: benrejebmoh <[email protected]> * resolve sonar issue fix regex matching with upper/lower case Signed-off-by: benrejebmoh <[email protected]> * fix search focus Signed-off-by: benrejebmoh <[email protected]> * remove unnecessary check when focusing on HTMLElement Signed-off-by: benrejebmoh <[email protected]> * resolve conflict with main Signed-off-by: benrejebmoh <[email protected]> * Remove useless setTimeout Signed-off-by: Franck LECUYER <[email protected]> --------- Signed-off-by: benrejebmoh <[email protected]> Signed-off-by: Franck LECUYER <[email protected]> Co-authored-by: Franck LECUYER <[email protected]>
1 parent f4a1d87 commit 9091f2c

File tree

6 files changed

+384
-6
lines changed

6 files changed

+384
-6
lines changed

src/components/spreadsheet-view/spreadsheet/spreadsheet-toolbar/global-model-editor/formula-editor.tsx

Lines changed: 98 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,106 @@
55
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
66
*/
77
import { ExpandingTextField, type ExpandingTextFieldProps, type SxStyle } from '@gridsuite/commons-ui';
8+
import { Box } from '@mui/material';
9+
import { useTheme } from '@mui/material/styles';
10+
import { useMemo } from 'react';
11+
import { useFormContext, useWatch } from 'react-hook-form';
12+
import { useFormulaSearch } from './formula-search-context';
813

914
const styles = {
10-
flexGrow: 1,
11-
} as const satisfies SxStyle;
15+
container: {
16+
position: 'relative',
17+
flexGrow: 1,
18+
},
19+
overlay: {
20+
position: 'absolute',
21+
top: 0,
22+
left: 0,
23+
width: '100%',
24+
height: '100%',
25+
whiteSpace: 'pre-wrap',
26+
color: 'transparent',
27+
pointerEvents: 'none',
28+
padding: '16.5px 14px',
29+
boxSizing: 'border-box',
30+
},
31+
textField: {
32+
position: 'relative',
33+
backgroundColor: 'transparent',
34+
},
35+
} as const satisfies Record<'container' | 'overlay' | 'textField', SxStyle>;
36+
37+
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1238

1339
export default function FormulaEditor({ name }: Readonly<ExpandingTextFieldProps>) {
14-
return <ExpandingTextField name={name} label="" minRows={3} rows={3} sx={styles} />;
40+
const theme = useTheme();
41+
const { control } = useFormContext();
42+
const value = useWatch({ name, control }) as string | undefined;
43+
const { searchTerm } = useFormulaSearch();
44+
45+
const overlaySx = useMemo(() => {
46+
const { fontFamily, body1 } = theme.typography;
47+
return {
48+
...styles.overlay,
49+
fontFamily,
50+
fontSize: body1?.fontSize,
51+
fontWeight: body1?.fontWeight,
52+
lineHeight: '1.4375em',
53+
letterSpacing: body1?.letterSpacing,
54+
} satisfies SxStyle;
55+
}, [theme]);
56+
57+
const highlighted = useMemo(() => {
58+
const formula = value ?? '';
59+
if (!searchTerm) {
60+
return formula;
61+
}
62+
63+
const escaped = escapeRegExp(searchTerm);
64+
const regex = new RegExp(escaped, 'gi');
65+
const nodes: (string | JSX.Element)[] = [];
66+
let lastIndex = 0;
67+
let match: RegExpExecArray | null;
68+
let occurrence = 0;
69+
70+
while ((match = regex.exec(formula)) !== null) {
71+
const matchIndex = match.index;
72+
const matchText = match[0];
73+
74+
if (matchIndex > lastIndex) {
75+
nodes.push(formula.slice(lastIndex, matchIndex));
76+
}
77+
78+
nodes.push(
79+
<span
80+
key={`${matchIndex}-${occurrence}`}
81+
style={{ backgroundColor: theme.searchedText.highlightColor }}
82+
>
83+
{matchText}
84+
</span>
85+
);
86+
87+
lastIndex = matchIndex + matchText.length;
88+
occurrence += 1;
89+
90+
if (matchText.length === 0) {
91+
regex.lastIndex += 1;
92+
}
93+
}
94+
95+
if (lastIndex < formula.length) {
96+
nodes.push(formula.slice(lastIndex));
97+
}
98+
99+
return nodes.length > 0 ? nodes : formula;
100+
}, [searchTerm, theme.searchedText.highlightColor, value]);
101+
102+
return (
103+
<Box sx={styles.container}>
104+
<Box aria-hidden sx={overlaySx}>
105+
{highlighted}
106+
</Box>
107+
<ExpandingTextField name={name} label="" minRows={3} rows={3} sx={styles.textField} />
108+
</Box>
109+
);
15110
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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, { createContext, useContext, useState } from 'react';
8+
9+
export type FormulaSearchContextType = {
10+
searchTerm: string;
11+
setSearchTerm: (value: string) => void;
12+
searchResults: number[];
13+
setSearchResults: (value: number[]) => void;
14+
currentResultIndex: number;
15+
setCurrentResultIndex: (value: number) => void;
16+
};
17+
18+
const FormulaSearchContext = createContext<FormulaSearchContextType | undefined>(undefined);
19+
20+
export const FormulaSearchProvider = ({ children }: { children: React.ReactNode }) => {
21+
const [searchTerm, setSearchTerm] = useState('');
22+
const [searchResults, setSearchResults] = useState<number[]>([]);
23+
const [currentResultIndex, setCurrentResultIndex] = useState(-1);
24+
25+
return (
26+
<FormulaSearchContext.Provider
27+
value={{
28+
searchTerm,
29+
setSearchTerm,
30+
searchResults,
31+
setSearchResults,
32+
currentResultIndex,
33+
setCurrentResultIndex,
34+
}}
35+
>
36+
{children}
37+
</FormulaSearchContext.Provider>
38+
);
39+
};
40+
41+
// eslint-disable-next-line react-refresh/only-export-components
42+
export const useFormulaSearch = () => {
43+
const context = useContext(FormulaSearchContext);
44+
if (context === undefined) {
45+
throw new Error('useFormulaSearch must be used within a FormulaSearchProvider');
46+
}
47+
return context;
48+
};
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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

Comments
 (0)