Skip to content

Commit 92140c3

Browse files
authored
Add search button elements (#376)
Signed-off-by: Rehili Ghazwa <[email protected]>
1 parent 6c729d9 commit 92140c3

File tree

7 files changed

+316
-2
lines changed

7 files changed

+316
-2
lines changed

src/components/app-top-bar.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* License, v. 2.0. If a copy of the MPL was not distributed with this
55
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
66
*/
7-
import React, { useEffect, useState } from 'react';
7+
import React, { useEffect, useRef, useState } from 'react';
88
import { LIGHT_THEME, logout, TopBar } from '@gridsuite/commons-ui';
99
import ParametersDialog, {
1010
useParameterState,
@@ -22,6 +22,7 @@ import { ReactComponent as GridExploreLogoLight } from '../images/GridExplore_lo
2222
import { ReactComponent as GridExploreLogoDark } from '../images/GridExplore_logo_dark.svg';
2323
import { setAppsAndUrls } from '../redux/actions';
2424
import AppPackage from '../../package.json';
25+
import SearchBar from './search/search-bar';
2526

2627
const AppTopBar = ({ user, userManager }) => {
2728
const navigate = useNavigate();
@@ -39,6 +40,8 @@ const AppTopBar = ({ user, userManager }) => {
3940

4041
const [showParameters, setShowParameters] = useState(false);
4142

43+
const searchInputRef = useRef(null);
44+
4245
useEffect(() => {
4346
if (user !== null) {
4447
fetchAppsAndUrls().then((res) => {
@@ -47,6 +50,23 @@ const AppTopBar = ({ user, userManager }) => {
4750
}
4851
}, [user, dispatch]);
4952

53+
useEffect(() => {
54+
if (user) {
55+
const openSearch = (e) => {
56+
if (
57+
e.ctrlKey &&
58+
e.shiftKey &&
59+
(e.key === 'F' || e.key === 'f')
60+
) {
61+
e.preventDefault();
62+
searchInputRef.current.focus();
63+
}
64+
};
65+
document.addEventListener('keydown', openSearch);
66+
return () => document.removeEventListener('keydown', openSearch);
67+
}
68+
}, [user]);
69+
5070
return (
5171
<>
5272
<TopBar
@@ -73,7 +93,9 @@ const AppTopBar = ({ user, userManager }) => {
7393
fetchVersion().then((res) => res?.deployVersion)
7494
}
7595
additionalModulesPromise={getServersInfos}
76-
/>
96+
>
97+
{user && <SearchBar inputRef={searchInputRef} />}
98+
</TopBar>
7799
<ParametersDialog
78100
showParameters={showParameters}
79101
hideParameters={() => setShowParameters(false)}

src/components/app-wrapper.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
common_button_fr,
3535
directory_items_input_fr,
3636
directory_items_input_en,
37+
element_search_fr,
38+
element_search_en,
3739
} from '@gridsuite/commons-ui';
3840
import { IntlProvider } from 'react-intl';
3941
import { BrowserRouter } from 'react-router-dom';
@@ -184,6 +186,7 @@ const messages = {
184186
...common_button_en,
185187
...backend_locale_en,
186188
...directory_items_input_en,
189+
...element_search_en,
187190
...messages_plugins_en, // keep it at the end to allow translation overwritting
188191
},
189192
fr: {
@@ -199,6 +202,7 @@ const messages = {
199202
...multiple_selection_dialog_fr,
200203
...common_button_fr,
201204
...backend_locale_fr,
205+
...element_search_fr,
202206
...aggrid_locale_fr, // Only the french locale is needed
203207
...directory_items_input_fr,
204208
...messages_plugins_fr, // keep it at the end to allow translation overwritting
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Copyright (c) 2024, 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, useEffect, useRef, useState } from 'react';
8+
import { Autocomplete, TextField } from '@mui/material';
9+
import {
10+
fetchDirectoryContent,
11+
searchElementsInfos,
12+
} from '../../utils/rest-api';
13+
import { useDebounce, useSnackMessage } from '@gridsuite/commons-ui';
14+
import { Search } from '@mui/icons-material';
15+
import { useDispatch, useSelector } from 'react-redux';
16+
import { setSelectedDirectory, setTreeData } from '../../redux/actions';
17+
import { updatedTree } from '../tree-views-container';
18+
import { useIntl } from 'react-intl';
19+
import SearchItem from './search-item';
20+
21+
export const SEARCH_FETCH_TIMEOUT_MILLIS = 1000; // 1 second
22+
23+
export const SearchBar = ({ inputRef }) => {
24+
const dispatch = useDispatch();
25+
const { snackError } = useSnackMessage();
26+
const [elementsFound, setElementsFound] = useState([]);
27+
const [inputValue, onInputChange] = useState('');
28+
const lastSearchTermRef = useRef('');
29+
const [loading, setLoading] = useState(false);
30+
const treeData = useSelector((state) => state.treeData);
31+
const treeDataRef = useRef();
32+
const intl = useIntl();
33+
treeDataRef.current = treeData;
34+
const searchMatchingEquipments = useCallback(
35+
(searchTerm) => {
36+
lastSearchTermRef.current = searchTerm;
37+
searchTerm &&
38+
searchElementsInfos(searchTerm)
39+
.then((infos) => {
40+
if (infos.length) {
41+
setElementsFound(infos);
42+
} else {
43+
setElementsFound([]);
44+
}
45+
})
46+
.catch((error) => {
47+
snackError({
48+
messageTxt: error.message,
49+
headerId: 'elementsSearchingError',
50+
});
51+
});
52+
},
53+
[snackError]
54+
);
55+
56+
const debouncedSearchMatchingElements = useDebounce(
57+
searchMatchingEquipments,
58+
SEARCH_FETCH_TIMEOUT_MILLIS
59+
);
60+
61+
const handleChangeInput = useCallback(
62+
(searchTerm) => {
63+
onInputChange(searchTerm);
64+
searchTerm && setLoading(true);
65+
debouncedSearchMatchingElements(searchTerm);
66+
},
67+
[debouncedSearchMatchingElements]
68+
);
69+
70+
useEffect(() => {
71+
elementsFound !== undefined && setLoading(false);
72+
}, [elementsFound]);
73+
74+
const renderOptionItem = useCallback(
75+
(props, option) => {
76+
const matchingElement = elementsFound.find(
77+
(element) => element.id === option.id
78+
);
79+
return (
80+
<SearchItem
81+
matchingElement={matchingElement}
82+
inputValue={inputValue}
83+
{...props}
84+
/>
85+
);
86+
},
87+
[elementsFound, inputValue]
88+
);
89+
90+
const updateMapData = useCallback(
91+
(nodeId, children) => {
92+
let [newRootDirectories, newMapData] = updatedTree(
93+
treeDataRef.current.rootDirectories,
94+
treeDataRef.current.mapData,
95+
nodeId,
96+
children
97+
);
98+
dispatch(
99+
setTreeData({
100+
rootDirectories: newRootDirectories,
101+
mapData: newMapData,
102+
})
103+
);
104+
},
105+
[dispatch]
106+
);
107+
108+
const handleDispatchDirectory = useCallback(
109+
(elementUuidPath) => {
110+
const selectedDirectory =
111+
treeDataRef.current.mapData[elementUuidPath];
112+
113+
dispatch(setSelectedDirectory(selectedDirectory));
114+
},
115+
[dispatch]
116+
);
117+
118+
const handleMatchingElement = useCallback(
119+
(data) => {
120+
if (data !== undefined) {
121+
const matchingElement = elementsFound.find(
122+
(element) => element === data
123+
);
124+
const elementUuidPath = matchingElement?.pathUuid.reverse();
125+
126+
const promises = elementUuidPath.map((e) => {
127+
return fetchDirectoryContent(e)
128+
.then((res) => {
129+
updateMapData(e, res);
130+
})
131+
.catch((error) =>
132+
snackError({
133+
messageTxt: error.message,
134+
headerId: 'pathRetrievingError',
135+
})
136+
);
137+
});
138+
139+
Promise.all(promises).then(() => {
140+
const lastElement = elementUuidPath.pop();
141+
handleDispatchDirectory(lastElement);
142+
});
143+
}
144+
},
145+
[elementsFound, updateMapData, handleDispatchDirectory, snackError]
146+
);
147+
148+
return (
149+
<>
150+
<Autocomplete
151+
sx={{ width: '50%', marginLeft: '14%' }}
152+
freeSolo
153+
size="small"
154+
disableClearable={false}
155+
forcePopupIcon={false}
156+
clearOnBlur
157+
autoHighlight={true}
158+
isOptionEqualToValue={(option, value) => option.id === value.id}
159+
inputValue={inputValue}
160+
onInputChange={(_, data) => handleChangeInput(data)}
161+
onChange={handleMatchingElement}
162+
key={(option) => option.id}
163+
options={loading ? [] : elementsFound}
164+
getOptionLabel={(option) => option.name}
165+
loading={loading}
166+
renderOption={renderOptionItem}
167+
renderInput={(params) => (
168+
<TextField
169+
autoFocus={true}
170+
{...params}
171+
inputRef={inputRef}
172+
placeholder={intl.formatMessage({
173+
id: 'searchPlaceholder',
174+
})}
175+
variant="outlined"
176+
InputProps={{
177+
...params.InputProps,
178+
startAdornment: (
179+
<React.Fragment>
180+
<Search />
181+
{params.InputProps.startAdornment}
182+
</React.Fragment>
183+
),
184+
}}
185+
/>
186+
)}
187+
/>
188+
</>
189+
);
190+
};
191+
192+
export default SearchBar;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Copyright (c) 2024, 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 { getFileIcon } from '@gridsuite/commons-ui';
8+
import Grid from '@mui/material/Grid';
9+
import Typography from '@mui/material/Typography';
10+
import { FormattedMessage } from 'react-intl';
11+
12+
const styles = {
13+
icon: (theme) => ({
14+
marginRight: theme.spacing(2),
15+
width: '18px',
16+
height: '18px',
17+
}),
18+
grid: {
19+
overflow: 'hidden',
20+
textOverflow: 'ellipsis',
21+
display: 'inline-block',
22+
},
23+
grid2: (theme) => ({
24+
marginRight: theme.spacing(2),
25+
overflow: 'hidden',
26+
textOverflow: 'ellipsis',
27+
display: 'inline-block',
28+
color: 'grey',
29+
}),
30+
};
31+
32+
function HighlightedText({ text, highlight }) {
33+
const parts = text.split(new RegExp(`(${highlight})`, 'gi'));
34+
35+
return (
36+
<span>
37+
{parts.map((part, i) =>
38+
part.toLowerCase() === highlight.toLowerCase() ? (
39+
<span key={i} style={{ fontWeight: 'bold' }}>
40+
{part}
41+
</span>
42+
) : (
43+
part
44+
)
45+
)}
46+
</span>
47+
);
48+
}
49+
50+
function SearchItem({ matchingElement, inputValue, ...othersProps }) {
51+
return (
52+
<li {...othersProps}>
53+
<>
54+
<span>{getFileIcon(matchingElement.type, styles.icon)}</span>
55+
<Grid container>
56+
<Grid item xs={11} sx={styles.grid}>
57+
<HighlightedText
58+
text={matchingElement.name}
59+
highlight={inputValue}
60+
/>
61+
</Grid>
62+
<Grid item sx={styles.grid2}>
63+
<Typography>
64+
<FormattedMessage id="path" />
65+
{matchingElement.pathName?.join(' / ')}
66+
</Typography>
67+
</Grid>
68+
</Grid>
69+
</>
70+
</li>
71+
);
72+
}
73+
74+
export default SearchItem;

src/translations/en.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@
289289
"NoDirectorySelectedError": "A directory must be selected to restore elements",
290290
"RestoreElementsInPrivateDirectoryError": "Some or all the elements can not be restored: To perform a restoration in a private folder it is necessary to be the owner of all the restored elements",
291291
"RestoreElementsInPublicDirectoryError": "Some or all the elements can not be restored: To perform a restoration in a public folder it is necessary to either be the owner of all the restored elements or the restored elements must be public",
292+
"DeleteElementFromStashError": "{multiselect, select, false {This element could not be deleted because it was created by another user} true {A subset of elements could not be deleted because they were created by another user} other {}}",
293+
"path": "path : ",
294+
"searchPlaceholder": "Search elements (ex.: case, filter...)",
295+
"elementsSearchingError": "An error occurred while searching the elements",
292296
"loadType": "Type",
293297
"Undefined": "Undefined",
294298
"Auxiliary": "Auxiliary",

src/translations/fr.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@
289289
"NoDirectorySelectedError": "Un dossier doit être sélectionné pour restaurer les éléments",
290290
"RestoreElementsInPrivateDirectoryError": "Certains ou tous les éléments ne peuvent pas être restaurés : Pour effectuer une restauration dans un dossier privé il est nécessaire d'être propriétaire de tous les éléments restaurés",
291291
"RestoreElementsInPublicDirectoryError": "Certains ou tous les éléments ne peuvent pas être restaurés : Pour effectuer une restauration dans un dossier public il est nécessaire soit d'être propriétaire de tous les éléments restaurés, soit les éléments restaurés doivent être publics",
292+
"DeleteElementFromStashError": "{multiselect, select, false {L'élément n'a pas pu être supprimé car il a été créé par un autre utilisateur} true {Certains éléments n'ont pas pu être supprimés car ils ont été créés par d'autres utilisateurs} other {}}",
293+
"path": "emplacement : ",
294+
"searchPlaceholder": "Rechercher des elements (ex.: étude, filtre...)",
295+
"elementsSearchingError": "Une erreur s'est produite lors de la recherche des éléments",
292296
"loadType": "Type",
293297
"Undefined": "Non défini",
294298
"Auxiliary": "Auxiliaire",

src/utils/rest-api.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,3 +1111,17 @@ export const getExportFormats = () => {
11111111
console.debug(url);
11121112
return backendFetchJson(url);
11131113
};
1114+
1115+
export function searchElementsInfos(searchTerm) {
1116+
console.info(
1117+
"Fetching elements infos matching with '%s' term ... ",
1118+
searchTerm
1119+
);
1120+
const urlSearchParams = new URLSearchParams();
1121+
urlSearchParams.append('userInput', searchTerm);
1122+
return backendFetchJson(
1123+
PREFIX_DIRECTORY_SERVER_QUERIES +
1124+
'/v1/elements/indexation-infos?' +
1125+
urlSearchParams.toString()
1126+
);
1127+
}

0 commit comments

Comments
 (0)