diff --git a/package.json b/package.json index c706491433ac7e31c08ae506d715918096870261..7c9b4baa75322ec979b12337dcd459fa6ac65247 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,17 @@ "@material-ui/lab": "^4.0.0-alpha.48", "@material-ui/styles": "^4.10.0", "downloadjs": "^1.4.7", + "i18next": "^23.11.2", + "i18next-http-backend": "^2.5.1", "moment": "^2.27.0", "mui-datatables": "^3.4.0", "ol": "^6.3.2-dev.1594217558556", + "proj4": "^2.11.0", "react": "^16.13.1", "react-dom": "^16.13.1", "react-hanger": "^2.2.1", "react-html-parser": "^2.0.2", + "react-i18next": "^14.1.1", "react-router-dom": "^5.2.0", "react-scripts": "^3.3.0" }, @@ -45,12 +49,12 @@ ] }, "devDependencies": { - "jscs": "^3.0.7", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", "husky": "8.0.3", + "jscs": "^3.0.7", "lint-staged": "14.0.1", - "prettier": "^3.2.5", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.1.3" + "prettier": "^3.2.5" }, "husky": { "hooks": { diff --git a/public/index.html b/public/index.html index 38223fe591121af96fe75b88c0b43242e7ad2dd6..5e6c56481c2b2e5db90e8168d6f8261aab9e9f72 100644 --- a/public/index.html +++ b/public/index.html @@ -1,17 +1,11 @@ <!DOCTYPE html> <html lang="en"> - <head> <meta charset="utf-8" /> - <!-- <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" /> --> + <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> - <!-- - manifest.json provides metadata used when your web app is installed on a - user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ - --> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> - <!-- Notice the use of %PUBLIC_URL% in the tags above. It will be replaced with the URL of the `public` folder during the build. @@ -21,23 +15,11 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - <title>IN-SYLVA SEARCH</title> + <title>IN-SYLVA Search</title> <script src="%PUBLIC_URL%/env-config.js"></script> </head> - <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> - <!-- - This HTML file is a template. - If you open it directly in the browser, you will see an empty page. - - You can add webfonts, meta tags, or analytics to this file. - The build step will place the bundled scripts into the <body> tag. - - To begin the development, run `npm start` or `yarn start`. - To create a production bundle, use `npm run build` or `yarn build`. - --> </body> - </html> diff --git a/public/locales/en/common.json b/public/locales/en/common.json new file mode 100644 index 0000000000000000000000000000000000000000..506293c381c1ba92b8c840aaea2d59b85ba3c993 --- /dev/null +++ b/public/locales/en/common.json @@ -0,0 +1,13 @@ +{ + "languages": { + "en": "English", + "fr": "French" + }, + "inSylvaLogoAlt": "In-Sylva logo", + "validationActions": { + "cancel": "Cancel", + "send": "Send", + "save": "Save", + "validate": "Validate" + } +} diff --git a/public/locales/en/header.json b/public/locales/en/header.json new file mode 100644 index 0000000000000000000000000000000000000000..889a13d8020dc576da3ac10b65ed7e7c394204f6 --- /dev/null +++ b/public/locales/en/header.json @@ -0,0 +1,11 @@ +{ + "tabs": { + "home": "Home", + "search": "Search" + }, + "userMenu": { + "title": "User profile", + "editProfileButton": "Edit profile", + "logOutButton": "Log out" + } +} diff --git a/public/locales/en/home.json b/public/locales/en/home.json new file mode 100644 index 0000000000000000000000000000000000000000..d1038e005f2484a8a1e436071e369bfadee70574 --- /dev/null +++ b/public/locales/en/home.json @@ -0,0 +1,11 @@ +{ + "pageTitle": "Welcome on In-Sylva search module's homepage.", + "searchToolDescription": { + "part1": "As a reminder, it should be remembered that the metadata stored in IN-SYLVA IS are structured around the IN-SYLVA standard.", + "part2": "This standard is composed of metadata fields. A metadata record is therefore made up of a series of fields accompanied by their value.", + "part3": "With this part of the interface you will be able to search for metadata records (previously loaded via the portal), by defining a certain number of criteria.", + "part4": "By default the \"search\" interface opens to a \"plain text\" search, ie the records returned in the result are those which, in one of the field values, contains the supplied character string.", + "part5": "A click on the Advanced search button gives access to a more complete form via which you can do more precise searches on one or more targeted fields.", + "part6": "Click on the \"Search\" tab to access the search interface." + } +} diff --git a/public/locales/en/maps.json b/public/locales/en/maps.json new file mode 100644 index 0000000000000000000000000000000000000000..555595d382f4cd15870bd7552c8bb2d287ca77bd --- /dev/null +++ b/public/locales/en/maps.json @@ -0,0 +1,28 @@ +{ + "layersTableHeaders": { + "cartography": "Cartography", + "filters": "Filters", + "tools": "Tools" + }, + "layersTable": { + "openStreetMap": "Open Street Map", + "bingAerial": "Bing Aerial", + "IGN": "IGN map", + "queryResults": "Query results", + "regions": "Regions", + "departments": "Departments", + "sylvoEcoRegions": "SylvoEcoRegions", + "selectFilterOption": "Select a single option", + "zoomHelperText": "Use ctrl + scroll to zoom the map", + "selectionTool": { + "title": "Point selection mode", + "select": "Add", + "unselect": "Remove", + "unselectAll": "Unselect all points" + } + }, + "selectedPointsList": { + "title": "Selected resources list", + "empty": "Select resources to display them here." + } +} diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json new file mode 100644 index 0000000000000000000000000000000000000000..76ab2db9f4c52d0a9f25098cf4c7cc302f038eac --- /dev/null +++ b/public/locales/en/profile.json @@ -0,0 +1,23 @@ +{ + "pageTitle": "Profile management", + "groups": { + "groupsList": "Group list", + "groupName": "Name", + "groupDescription": "Description" + }, + "requestsList": { + "requestsList": "Requests list", + "requestsMessage": "Message", + "processed": "Processed", + "cancelRequest": "Cancel this request" + }, + "groupRequests": { + "requestGroupAssignment": "Request a group assignment", + "currentGroups": "You currently belong to (or have a pending request for) these groups:", + "noGroup": "You currently don't belong to any group." + }, + "roleRequests": { + "requestRoleAssignment": "Request an application role", + "currentRole": "You currently have (or have a pending request for) this role:" + } +} diff --git a/public/locales/en/results.json b/public/locales/en/results.json new file mode 100644 index 0000000000000000000000000000000000000000..baaaa092998cf918fd7cbb01b758a76a4cc257f6 --- /dev/null +++ b/public/locales/en/results.json @@ -0,0 +1,47 @@ +{ + "clickOnRowTip": "Click on a row to display metadata.", + "downloadResultsButton": { + "JSON": "Download as JSON" + }, + "table": { + "title": "Search results from query: <strong>{{searchQuery}}</strong>", + "search": "Search among results", + "displaySelectedRowsButton": "Sort selection", + "textLabels": { + "body": { + "noMatch": "No matching records found", + "toolTip": "Sort" + }, + "pagination": { + "next": "Next Page", + "previous": "Previous Page", + "rowsPerPage": "Rows per page:", + "displayRows": "of", + "jumpToPage": "Page:" + }, + "toolbar": { + "search": "Search", + "viewColumns": "View Columns", + "filterTable": "Filter Table" + }, + "filter": { + "all": "All", + "title": "FILTERS", + "reset": "RESET" + }, + "viewColumns": { + "title": "Show Columns", + "titleAria": "Show/Hide Table Columns" + }, + "selectedRows": { + "text": "row(s) selected" + } + } + }, + "flyout": { + "label": "Resource data sheet", + "JSON": { + "title": "Resource JSON data" + } + } +} diff --git a/public/locales/en/search.json b/public/locales/en/search.json new file mode 100644 index 0000000000000000000000000000000000000000..cebc9ebd923a6e84a544c21650da1a833a2c1bea --- /dev/null +++ b/public/locales/en/search.json @@ -0,0 +1,78 @@ +{ + "pageTitle": "In-Sylva Metadata Search Platform", + "tabs": { + "composeSearch": "Compose search", + "results": "Results", + "map": "Map" + }, + "sendSearchButton": "Search", + "basicSearch": { + "switchSearchMode": "Switch to advanced search", + "searchInputPlaceholder": "Search..." + }, + "advancedSearch": { + "switchSearchMode": "Switch to basic search", + "textQueryPlaceholder": "Add fields...", + "countResultsButton": "Count results", + "resultsCount_one": "{{count}} result", + "resultsCount_other": "{{count}} results", + "editableSearchButton": "Editable", + "errorInvalidOption": "\"{{value}}\" is not a valid option.", + "fields": { + "title": "Field search", + "loadingFields": "Loading fields...", + "removeFieldButton": "Remove field", + "clearValues": "Clear values", + "addFieldPopover": { + "openPopoverButton": "Add field", + "title": "Select a field", + "button": "Add this field", + "selectSection": "Select a section" + }, + "fieldContentPopover": { + "addFieldValues": "Add field values", + "addValue": "Add value", + "firstValue": "1st value", + "secondValue": "2nd value", + "inputTextValue": "Type value", + "betweenDate": "between", + "andDate": "and", + "selectValues": "Select values" + } + }, + "searchHistory": { + "placeholder": "Load a previous request", + "saveSearch": "Save search", + "addSavedSearchName": "Search name", + "addSavedSearchDescription": "Description (optional)", + "addSavedSearchDescriptionPlaceholder": "Search description..." + }, + "searchOptions": { + "title": "Search option", + "matchAll": "Match all criterias", + "matchAtLeastOne": "Match at least one criteria" + }, + "partnerSources": { + "title": "Partner sources", + "allSourcesSelected": "By default, all sources are selected", + "noSourceAvailable": "No source available." + }, + "policyToast": { + "title": "Private field selected", + "content": [ + "You selected a private field.", + "Access to this field was granted for specific sources, which means that your search will be restricted to those.", + "Please check the sources list before searching." + ] + }, + "editableQueryToast": { + "title": "Proceed with caution", + "content": { + "part1": "Manually editing can spoil query results. Syntax must be respected:", + "part2": "Fields and their values should be put between brackets: { } - Make sure every opened bracket is closed", + "part3": "\"AND\" and \"OR\" should be capitalized between fields and lowercase within a field expression", + "part4": "Make sure to check for typing errors" + } + } + } +} diff --git a/public/locales/en/validation.json b/public/locales/en/validation.json new file mode 100644 index 0000000000000000000000000000000000000000..b12bd6a67aead265e9d0262c00b61d58d51137f7 --- /dev/null +++ b/public/locales/en/validation.json @@ -0,0 +1,3 @@ +{ + "requestSent": "Your request has been sent to the administrators." +} diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json new file mode 100644 index 0000000000000000000000000000000000000000..8e9085fbe434c4ceab454e939e90ab0139dbef0e --- /dev/null +++ b/public/locales/fr/common.json @@ -0,0 +1,13 @@ +{ + "languages": { + "en": "Anglais", + "fr": "Français" + }, + "inSylvaLogoAlt": "Logo In-Sylva", + "validationActions": { + "cancel": "Annuler", + "send": "Envoyer", + "save": "Sauvegarder", + "validate": "Valider" + } +} diff --git a/public/locales/fr/header.json b/public/locales/fr/header.json new file mode 100644 index 0000000000000000000000000000000000000000..a5e28a5e7bc627ee31d4145c909be2da802a29d4 --- /dev/null +++ b/public/locales/fr/header.json @@ -0,0 +1,11 @@ +{ + "tabs": { + "home": "Page d'accueil", + "search": "Recherche" + }, + "userMenu": { + "title": "Profil utilisateur", + "editProfileButton": "Modifier mon profil", + "logOutButton": "Déconnexion" + } +} diff --git a/public/locales/fr/home.json b/public/locales/fr/home.json new file mode 100644 index 0000000000000000000000000000000000000000..f9bdcd684df9cd4074239e84b369a5a97893ffcc --- /dev/null +++ b/public/locales/fr/home.json @@ -0,0 +1,11 @@ +{ + "pageTitle": "Bienvenue sur la page d'accueil du module de recherche du Système d'Information In-Sylva", + "searchToolDescription": { + "part1": "Il est important de rappeler que les métadonnées stockées dans le SI In-Sylva sont structurées autour du standard établi par In-Sylva.", + "part2": "Il est composé de champs de métadonnées. Une fiche de métadonnées est donc constituée d'une série de champs accompagnés de leur valeur.", + "part3": "Cette interface vous permettra de rechercher des fiches de métadonnées (chargées au préalable par le Portal), en définissant un certain nombre de critères.", + "part4": "L'interface \"Recherche\" ouvre une zone de texte de \"Recherche basique\". Les résultats correspondent aux fiches de métadonnées contenant, dans un de leurs champs, la chaîne de caractère renseignée.", + "part5": "Un click sur le bouton \"Recherche avancée\" vous permets d'accéder à un formulaire plus complet qui vous permettra des recherches plus précises sur un ou plusieurs champs donnés.", + "part6": "Clickez sur l'onglet \"Recherche\" pour accéder à l'interface de recherche." + } +} diff --git a/public/locales/fr/maps.json b/public/locales/fr/maps.json new file mode 100644 index 0000000000000000000000000000000000000000..fdbcd9b0ae3c35b92d6b57e63c62952ec5d92e76 --- /dev/null +++ b/public/locales/fr/maps.json @@ -0,0 +1,28 @@ +{ + "layersTableHeaders": { + "cartography": "Cartographie", + "filters": "Filtres", + "tools": "Outils" + }, + "layersTable": { + "openStreetMap": "Open Street Map", + "bingAerial": "Bing vue aérienne", + "IGN": "Plan IGN", + "queryResults": "Résultats de la requête", + "regions": "Régions", + "departments": "Départements", + "sylvoEcoRegions": "SylvoEcoRégions", + "selectFilterOption": "Sélectionnez une option", + "zoomHelperText": "Utilisez ctrl + scroll pour zoomer", + "selectionTool": { + "title": "Mode de sélection de points", + "select": "Ajout", + "unselect": "Suppression", + "unselectAll": "Vider la sélection" + } + }, + "selectedPointsList": { + "title": "Liste des ressources sélectionnées", + "empty": "Sélectionnez des ressources pour les afficher ici" + } +} diff --git a/public/locales/fr/profile.json b/public/locales/fr/profile.json new file mode 100644 index 0000000000000000000000000000000000000000..9f30ddb283a8147bd3dff379de9a808674709736 --- /dev/null +++ b/public/locales/fr/profile.json @@ -0,0 +1,23 @@ +{ + "pageTitle": "Gestion du profil", + "groups": { + "groupsList": "Liste des groupes", + "groupName": "Nom", + "groupDescription": "Description" + }, + "requestsList": { + "requestsList": "Liste des requêtes", + "requestsMessage": "Message", + "processed": "Traitée", + "cancelRequest": "Annuler cette requête" + }, + "groupRequests": { + "requestGroupAssignment": "Demander à faire parti d'un groupe", + "currentGroups": "Vous faites actuellement parti (ou avez une demande pour) de ces groupes :", + "noGroup": "Vous ne faites actuellement parti d'aucun groupe." + }, + "roleRequests": { + "requestRoleAssignment": "Demander un rôle", + "currentRole": "Votre rôle actuel (ou demande en cours):" + } +} diff --git a/public/locales/fr/results.json b/public/locales/fr/results.json new file mode 100644 index 0000000000000000000000000000000000000000..1237c231591e493969bca0aa7d0b3451f9ea6db2 --- /dev/null +++ b/public/locales/fr/results.json @@ -0,0 +1,47 @@ +{ + "clickOnRowTip": "Clickez sur une ligne du tableau pour afficher ses métadonnées.", + "downloadResultsButton": { + "JSON": "Télécharger en JSON" + }, + "table": { + "title": "Résultats de la requête : <strong>{{searchQuery}}</strong>", + "search": "Chercher parmi les résultats", + "displaySelectedRowsButton": "Trier la sélection", + "textLabels": { + "body": { + "noMatch": "Aucun résultat pour cette requête", + "toolTip": "Trier" + }, + "pagination": { + "next": "Page suivante", + "previous": "Page précédente", + "rowsPerPage": "Lignes par page :", + "displayRows": "sur", + "jumpToPage": "Page :" + }, + "toolbar": { + "search": "Rechercher", + "viewColumns": "Visualisation des colonnes", + "filterTable": "Filtrer les résultats" + }, + "filter": { + "all": "Tout", + "title": "FILTRES", + "reset": "REINITIALISER" + }, + "viewColumns": { + "title": "Visualisation des colonnes", + "titleAria": "Montrer/cacher les colonnes" + }, + "selectedRows": { + "text": "ligne(s) sélectionnées" + } + } + }, + "flyout": { + "label": "Fiche de données de la ressource", + "JSON": { + "title": "Données JSON de la ressource" + } + } +} diff --git a/public/locales/fr/search.json b/public/locales/fr/search.json new file mode 100644 index 0000000000000000000000000000000000000000..5f6ab3907ccd0c6176b0d8b81d03c3f1d808f28b --- /dev/null +++ b/public/locales/fr/search.json @@ -0,0 +1,78 @@ +{ + "pageTitle": "Plateforme de recherche de métadonnées In-Sylva", + "tabs": { + "composeSearch": "Composer une recherche", + "results": "Résultats", + "map": "Carte" + }, + "sendSearchButton": "Lancer la recherche", + "basicSearch": { + "switchSearchMode": "Passer en recherche avancée", + "searchInputPlaceholder": "Chercher..." + }, + "advancedSearch": { + "switchSearchMode": "Passer en recherche basique", + "textQueryPlaceholder": "Ajoutez des champs...", + "countResultsButton": "Compter les résultats", + "resultsCount_one": "{{count}} résultat", + "resultsCount_other": "{{count}} résultats", + "editableSearchButton": "Modifiable", + "errorInvalidOption": "\"{{value}}\" n'est pas une option valide.", + "fields": { + "title": "Recherche de champ", + "loadingFields": "Chargement des champs...", + "removeFieldButton": "Supprimer le champ", + "clearValues": "Vider les valeurs", + "addFieldPopover": { + "openPopoverButton": "Selectionnez un champ", + "title": "Ajouter ce champ", + "button": "Selectionnez une section", + "selectSection": "Ajouter un champ" + }, + "fieldContentPopover": { + "addValue": "Ajouter une valeur", + "addFieldValues": "Ajouter des valeurs de champ", + "firstValue": "1ère valeur", + "secondValue": "2ème valeur", + "inputTextValue": "Entrez une valeur", + "betweenDate": "entre", + "andDate": "et", + "selectValues": "Sélectionnez au moins une valeur" + } + }, + "searchHistory": { + "placeholder": "Charger une recherche précédente", + "saveSearch": "Sauvegarder ma recherche", + "addSavedSearchName": "Nom de la recherche", + "addSavedSearchDescription": "Description (optionel)", + "addSavedSearchDescriptionPlaceholder": "Description de la recherche..." + }, + "searchOptions": { + "title": "Option de recherche", + "matchAll": "Répondre à tous les critères", + "matchAtLeastOne": "Répondre à au moins un critère" + }, + "partnerSources": { + "title": "Liste des sources de partenaires", + "allSourcesSelected": "Toutes les sources sont sélectionnées par défaut", + "noSourceAvailable": "Pas de source disponible." + }, + "policyToast": { + "title": "Champ privé sélectionné", + "content": [ + "Vous avez sélectionné un champ privé.", + "L'accès à ce champ à été donné par certaines sources, ce qui veut dire que votre recherche va être limitée à celles-ci.", + "Veuillez prếter attention à la liste des sources avant de continuer." + ] + }, + "editableQueryToast": { + "title": "Procéder avec prudence", + "content": { + "part1": "En éditant manuellement la recherche, vous pouvez facilement gâcher les résultats. Veuillez respecter la syntaxe :", + "part2": "Les champs et leurs valeurs doivent être comprises entre accolade: { } - Bien fermer toute accolade ouverte", + "part3": "\"AND\" et \"OR\" en majuscule entre les champs et en minuscule à l'intérieur d'une valeur de champ", + "part4": "Attention à corriger vos fautes de frappe" + } + } + } +} diff --git a/public/locales/fr/validation.json b/public/locales/fr/validation.json new file mode 100644 index 0000000000000000000000000000000000000000..0f29592894aacffcd6d069a539ed50c510cc7661 --- /dev/null +++ b/public/locales/fr/validation.json @@ -0,0 +1,3 @@ +{ + "requestSent": "Votre requête à bien été envoyée." +} diff --git a/src/App.js b/src/App.js index b73433f8138b04bf0f5d38a783fbf2a95c48970a..5d03570a71be6a36aa411c1f5608a974927660ad 100644 --- a/src/App.js +++ b/src/App.js @@ -5,13 +5,12 @@ import Layout from './components/Layout'; const App = () => { return ( - <EuiPage restrictWidth={false}> + <EuiPage style={{ padding: '0px' }} restrictWidth={false}> <EuiPageBody> <HashRouter> <Switch> <Route exact path="/" render={() => <Redirect to="/app/home" />} /> <Route component={Layout} /> - <Route component={Error} /> </Switch> </HashRouter> </EuiPageBody> diff --git a/src/Utils.js b/src/Utils.js index b36276204824efa394ca81a3d9d8d3f45d950135..3c4f4727b2209ff47dc4505960004c258bdf839a 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -594,14 +594,14 @@ export const createBasicQueriesBySource = ( sourcesLists.forEach((sourcesArray, index) => { let sourceParam = `"_source": [`; - fieldsLists[index].forEach((fieldname) => { - sourceParam = `${sourceParam} "${fieldname}", `; + fieldsLists[index].forEach((fieldName) => { + sourceParam = `${sourceParam} "${fieldName}", `; }); if (sourceParam.endsWith(', ')) { sourceParam = sourceParam.substring(0, sourceParam.length - 2); } sourceParam = `${sourceParam}],`; - let query = `{ ${sourceParam} "query": { "multi_match": { "query": "${searchRequest}" } } }`; + let query = `{ ${sourceParam} "query": { "multi_match": { "query": "${searchRequest}", "operator": "AND", "type": "cross_fields" } } }`; queries.push({ indicesId: indicesLists[index], query: JSON.parse(query) }); }); return queries; diff --git a/src/Map.svg b/src/assets/Map.svg similarity index 100% rename from src/Map.svg rename to src/assets/Map.svg diff --git a/src/favicon.svg b/src/assets/favicon.svg similarity index 100% rename from src/favicon.svg rename to src/assets/favicon.svg diff --git a/src/logo.svg b/src/assets/logo.svg similarity index 100% rename from src/logo.svg rename to src/assets/logo.svg diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js index f1e74d049f358e01ef1b3056e39b97508da1a37c..6e571d618929954064d06861039b3b0639c4b278 100644 --- a/src/components/Header/Header.js +++ b/src/components/Header/Header.js @@ -7,49 +7,52 @@ import { EuiHeaderLinks, EuiHeaderLink, } from '@elastic/eui'; -import HeaderUserMenu from './header_user_menu'; +import HeaderUserMenu from './HeaderUserMenu'; import style from './styles'; -import logoInSylva from '../../favicon.svg'; +import logoInSylva from '../../assets/favicon.svg'; +import { useTranslation } from 'react-i18next'; +import LanguageSwitcher from '../LanguageSwitcher/LanguageSwitcher'; const structure = [ { id: 0, - label: 'Home', + label: 'home', href: '/app/home', icon: '', }, { id: 1, - label: 'Search', + label: 'search', href: '/app/search', icon: '', }, ]; const Header = () => { + const { t } = useTranslation(['header', 'common']); + return ( <> <EuiHeader> <EuiHeaderSection grow={true}> - <EuiHeaderSectionItem border="right"> - <img - style={style} - src={logoInSylva} - width="75" - height="45" - alt="Logo INRAE" - /> + <EuiHeaderSectionItem> + <img style={style.logo} src={logoInSylva} alt={t('common:inSylvaLogoAlt')} /> </EuiHeaderSectionItem> - <EuiHeaderLinks border="right"> + <EuiHeaderLinks> {structure.map((link) => ( <EuiHeaderLink iconType="empty" key={link.id}> - <Link to={link.href}>{link.label}</Link> + <Link to={link.href}>{t(`tabs.${link.label}`)}</Link> </EuiHeaderLink> ))} </EuiHeaderLinks> </EuiHeaderSection> <EuiHeaderSection side="right"> - <EuiHeaderSectionItem>{HeaderUserMenu()}</EuiHeaderSectionItem> + <EuiHeaderSectionItem style={style.languageSwitcherItem} border={'none'}> + <LanguageSwitcher /> + </EuiHeaderSectionItem> + <EuiHeaderSectionItem style={style.userMenuItem} border={'none'}> + <HeaderUserMenu /> + </EuiHeaderSectionItem> </EuiHeaderSection> </EuiHeader> </> diff --git a/src/components/Header/HeaderUserMenu.js b/src/components/Header/HeaderUserMenu.js new file mode 100644 index 0000000000000000000000000000000000000000..2235573629ac2c2fa85fe3614f5e5b5dca6a43df --- /dev/null +++ b/src/components/Header/HeaderUserMenu.js @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react'; +import { + EuiAvatar, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiText, + EuiSpacer, + EuiPopover, + EuiButtonIcon, +} from '@elastic/eui'; +import { signOut } from '../../context/UserContext'; +import { findOneUser } from '../../actions/user'; +import { useTranslation } from 'react-i18next'; + +const HeaderUserMenu = () => { + const { t } = useTranslation('header'); + const [isOpen, setIsOpen] = useState(false); + const [user, setUser] = useState({}); + + const onMenuButtonClick = () => { + setIsOpen(!isOpen); + }; + + const closeMenu = () => { + setIsOpen(false); + }; + + useEffect(() => { + const loadUser = () => { + if (sessionStorage.getItem('user_id')) { + findOneUser(sessionStorage.getItem('user_id')).then((user) => { + setUser(user); + }); + } + }; + + loadUser(); + }, []); + + const HeaderUserButton = ( + <EuiButtonIcon + size="s" + onClick={onMenuButtonClick} + iconType="user" + title={t('userMenu.title')} + aria-label={t('userMenu.title')} + /> + ); + + return user.username ? ( + <EuiPopover + id="headerUserMenu" + ownFocus + button={HeaderUserButton} + isOpen={isOpen} + anchorPosition="downRight" + closePopover={closeMenu} + panelPaddingSize="none" + > + <div> + <EuiFlexGroup gutterSize="m" className="euiHeaderProfile" responsive={false}> + <EuiFlexItem grow={false}> + <EuiAvatar name={user.username} size="xl" /> + </EuiFlexItem> + <EuiFlexItem> + <EuiText>{user.username}</EuiText> + <EuiSpacer size="m" /> + <EuiFlexGroup> + <EuiFlexItem> + <EuiFlexGroup justifyContent="spaceBetween"> + <EuiFlexItem grow={false}> + <EuiLink href="#/app/profile"> + {t('userMenu.editProfileButton')} + </EuiLink> + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiLink onClick={() => signOut()}> + {t('userMenu.logOutButton')} + </EuiLink> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiFlexGroup> + </div> + </EuiPopover> + ) : ( + <></> + ); +}; + +export default HeaderUserMenu; diff --git a/src/components/Header/header_user_menu.js b/src/components/Header/header_user_menu.js deleted file mode 100644 index feb51bb45544c887a679289dab19aa1fa8401652..0000000000000000000000000000000000000000 --- a/src/components/Header/header_user_menu.js +++ /dev/null @@ -1,86 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - EuiAvatar, - EuiFlexGroup, - EuiFlexItem, - EuiLink, - EuiText, - EuiSpacer, - EuiPopover, - EuiButtonIcon, -} from '@elastic/eui'; -import { signOut } from '../../context/UserContext'; -import { findOneUser } from '../../actions/user'; - -export default function HeaderUserMenu() { - const [isOpen, setIsOpen] = useState(false); - const [user, setUser] = useState({}); - - const onMenuButtonClick = () => { - setIsOpen(!isOpen); - }; - - const closeMenu = () => { - setIsOpen(false); - }; - - const loadUser = () => { - if (sessionStorage.getItem('user_id')) { - findOneUser(sessionStorage.getItem('user_id')).then((user) => { - setUser(user); - }); - } - }; - - useEffect(() => { - loadUser(); - }, []); - - const HeaderUserButton = ( - <EuiButtonIcon - size="s" - onClick={onMenuButtonClick} - iconType="user" - title="User profile" - aria-label="User profile" - /> - ); - - return ( - user.username && ( - <EuiPopover - id="headerUserMenu" - ownFocus - button={HeaderUserButton} - isOpen={isOpen} - anchorPosition="downRight" - closePopover={closeMenu} - panelPaddingSize="none" - > - <div style={{ width: 320 }}> - <EuiFlexGroup gutterSize="m" className="euiHeaderProfile" responsive={false}> - <EuiFlexItem grow={false}> - <EuiAvatar name={user.username} size="xl" /> - </EuiFlexItem> - <EuiFlexItem> - <EuiText>{user.username}</EuiText> - <EuiSpacer size="m" /> - <EuiFlexGroup> - <EuiFlexItem> - <EuiFlexGroup justifyContent="spaceBetween"> - <EuiFlexItem grow={false}> - <EuiLink href="#/app/profile">Edit profile</EuiLink> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiLink onClick={() => signOut()}>Log out</EuiLink> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiFlexGroup> - </div> - </EuiPopover> - ) - ); -} diff --git a/src/components/Header/styles.js b/src/components/Header/styles.js index 2515727ce2391dc110ed0e581dfe850c9968dde9..94f8ecf22cfa183f26e33b3f3c7ae6086c4730ee 100644 --- a/src/components/Header/styles.js +++ b/src/components/Header/styles.js @@ -1,8 +1,15 @@ const headerStyle = { - paddingTop: '10px', - paddingBottom: '-6px', - paddingRight: '10px', - paddingLeft: '10px', + logo: { + width: '75px', + height: '75px', + padding: '10px', + }, + languageSwitcherItem: { + margin: '10px', + }, + userMenuItem: { + marginRight: '10px', + }, }; export default headerStyle; diff --git a/src/components/LanguageSwitcher/LanguageSwitcher.js b/src/components/LanguageSwitcher/LanguageSwitcher.js new file mode 100644 index 0000000000000000000000000000000000000000..7fb068cd82dad00d1b5b180728c1754869c3daf6 --- /dev/null +++ b/src/components/LanguageSwitcher/LanguageSwitcher.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './styles'; +import { EuiSelect } from '@elastic/eui'; + +const LanguageSwitcher = () => { + const { t, i18n } = useTranslation('common'); + + const options = [ + { text: t('languages.en'), value: 'en' }, + { text: t('languages.fr'), value: 'fr' }, + ]; + + const changeLanguage = (newLng) => { + i18n.changeLanguage(newLng).then(); + }; + + return ( + <EuiSelect + style={styles.select} + options={options} + compressed={true} + value={i18n.resolvedLanguage} + onChange={(e) => changeLanguage(e.target.value)} + /> + ); +}; + +export default LanguageSwitcher; diff --git a/src/components/LanguageSwitcher/styles.js b/src/components/LanguageSwitcher/styles.js new file mode 100644 index 0000000000000000000000000000000000000000..9c4c4bb7ae6b5014574eb8d32698e75136b9bcae --- /dev/null +++ b/src/components/LanguageSwitcher/styles.js @@ -0,0 +1,7 @@ +const styles = { + select: { + borderRadius: '6px', + }, +}; + +export default styles; diff --git a/src/components/Layout/Layout.js b/src/components/Layout/Layout.js index ddfcb8639542d0484050318d8aa67f6103a496a2..af5be085de322c47d3c135e68cab48602f40bc23 100644 --- a/src/components/Layout/Layout.js +++ b/src/components/Layout/Layout.js @@ -4,16 +4,20 @@ import Header from '../../components/Header'; import Search from '../../pages/search'; import Home from '../../pages/home'; import Profile from '../../pages/profile'; +import { EuiPageContent } from '@elastic/eui'; +import styles from './styles.js'; const Layout = () => { return ( <> <Header /> - <Switch> - <Route path="/app/home" component={Home} /> - <Route path="/app/search" component={Search} /> - <Route path="/app/profile" component={Profile} /> - </Switch> + <EuiPageContent style={styles.pageContent}> + <Switch> + <Route path="/app/home" component={Home} /> + <Route path="/app/search" component={Search} /> + <Route path="/app/profile" component={Profile} /> + </Switch> + </EuiPageContent> </> ); }; diff --git a/src/components/Layout/styles.js b/src/components/Layout/styles.js new file mode 100644 index 0000000000000000000000000000000000000000..787354eaa2957de1cc981112fa072887a75d71ca --- /dev/null +++ b/src/components/Layout/styles.js @@ -0,0 +1,9 @@ +const styles = { + pageContent: { + borderRadius: 0, + border: 0, + boxShadow: 'none', + }, +}; + +export default styles; diff --git a/src/components/Loading/Loading.js b/src/components/Loading/Loading.js new file mode 100644 index 0000000000000000000000000000000000000000..f5213a7dd183cad47b2cd4f933abd3a36fca42df --- /dev/null +++ b/src/components/Loading/Loading.js @@ -0,0 +1,12 @@ +import React from 'react'; +import styles from './styles'; + +const Loading = () => { + return ( + <div style={styles.container}> + <h1>Loading...</h1> + </div> + ); +}; + +export default Loading; diff --git a/src/components/Loading/package.json b/src/components/Loading/package.json new file mode 100644 index 0000000000000000000000000000000000000000..b4c70453df6e4755baccb4ff5e3783d092bca4e8 --- /dev/null +++ b/src/components/Loading/package.json @@ -0,0 +1,6 @@ +{ + "name": "Loading", + "version": "1.0.0", + "private": true, + "main": "Loading.js" +} diff --git a/src/components/Loading/styles.js b/src/components/Loading/styles.js new file mode 100644 index 0000000000000000000000000000000000000000..6c4d2706c3230a1d87dde4f06a41bcc1024c802f --- /dev/null +++ b/src/components/Loading/styles.js @@ -0,0 +1,11 @@ +const styles = { + container: { + width: '100vw', + height: '100vh', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, +}; + +export default styles; diff --git a/src/context/InSylvaKeycloakClient.js b/src/context/InSylvaKeycloakClient.js index c5980429eac474a69ea0cfb484c05fcc6cb3e91c..5a20fd65b1658b6b64c5bffe202cbbdaa517a61f 100644 --- a/src/context/InSylvaKeycloakClient.js +++ b/src/context/InSylvaKeycloakClient.js @@ -2,17 +2,11 @@ import { getLoginUrl } from '../Utils'; class InSylvaKeycloakClient { async post(path, requestContent) { - // const access_token = sessionStorage.getItem("access_token"); const headers = { 'Content-Type': 'application/x-www-form-urlencoded', // "Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE", // "Access-Control-Allow-Headers": "Origin, X-Requested-With, Content-Type, Accept, Authorization" }; - /* - if (access_token) { - headers["Authorization"] = "Bearer " + access_token - } - */ let formBody = []; for (const property in requestContent) { const encodedKey = encodeURIComponent(property); @@ -26,18 +20,14 @@ class InSylvaKeycloakClient { body: formBody, mode: 'cors', }); - if (response.ok === true) { - // ok - } else { + if (!response.ok) { await this.logout(); sessionStorage.removeItem('user_id'); sessionStorage.removeItem('access_token'); sessionStorage.removeItem('refresh_token'); window.location.replace(getLoginUrl() + '?requestType=search'); } - if (response.statusText === 'No Content') { - // ok - } else { + if (response.statusText !== 'No Content') { return await response.json(); } } @@ -58,11 +48,11 @@ class InSylvaKeycloakClient { return { token }; } - async logout({ realm = this.realm, client_id = this.client_id }) { + async logout() { const refresh_token = sessionStorage.getItem('refresh_token'); - const path = `/auth/realms/${realm}/protocol/openid-connect/logout`; if (refresh_token) { - await this.post(`${path}`, { + const client_id = this.client_id; + await this.post(`/auth/realms/${this.realm}/protocol/openid-connect/logout`, { client_id, refresh_token, }); diff --git a/src/context/InSylvaSearchClient.js b/src/context/InSylvaSearchClient.js index 6d702e250a32dd15308a0ef13ce9182a2957fbb5..ffdc831b72ef715a652caadace18b8e3f2c09730 100644 --- a/src/context/InSylvaSearchClient.js +++ b/src/context/InSylvaSearchClient.js @@ -35,8 +35,7 @@ class InSylvaSearchClient { for (let i = 0; i < queries.length; i++) { const indicesId = queries[i].indicesId; const query = queries[i].query; - const path = `/scroll-search`; - const result = await this.post('POST', `${path}`, { + const result = await this.post('POST', '/scroll-search', { indicesId, query, }); diff --git a/src/context/UserContext.js b/src/context/UserContext.js index 2d7b83dda5f22f5b484641bbebde7a92a2804728..d7578beb7e379f791fa08656d458dcb272e4193c 100644 --- a/src/context/UserContext.js +++ b/src/context/UserContext.js @@ -1,10 +1,10 @@ -import React from 'react'; +import React, { createContext, useContext, useReducer } from 'react'; import { InSylvaGatekeeperClient } from './InSylvaGatekeeperClient'; import { InSylvaKeycloakClient } from './InSylvaKeycloakClient'; import { getLoginUrl } from '../Utils'; -const UserStateContext = React.createContext(); -const UserDispatchContext = React.createContext(); +const UserStateContext = createContext(null); +const UserDispatchContext = createContext(null); const igClient = new InSylvaGatekeeperClient(); igClient.baseUrl = process.env.REACT_APP_IN_SYLVA_GATEKEEPER_PORT @@ -46,7 +46,7 @@ function userReducer(state, action) { } function UserProvider({ children }) { - const [state, dispatch] = React.useReducer(userReducer, { + const [state, dispatch] = useReducer(userReducer, { isAuthenticated: !!sessionStorage.getItem('access_token'), }); @@ -60,7 +60,7 @@ function UserProvider({ children }) { } function useUserState() { - const context = React.useContext(UserStateContext); + const context = useContext(UserStateContext); if (context === undefined) { throw new Error('useUserState must be used within a UserProvider'); } @@ -68,7 +68,7 @@ function useUserState() { } function useUserDispatch() { - const context = React.useContext(UserDispatchContext); + const context = useContext(UserDispatchContext); if (context === undefined) { throw new Error('useUserDispatch must be used within a UserProvider'); } @@ -85,7 +85,7 @@ async function checkUserLogin(userId, accessToken, refreshToken) { // Load the user result filters from Result_Filter(userId) endpoints // Load the user policies from Policy(userId) endpoint if (!sessionStorage.getItem('token_refresh_time')) { - sessionStorage.setItem('token_refresh_time', Date.now()); + sessionStorage.setItem('token_refresh_time', Date.now().toString()); } // dispatch({ type: "USER_LOGGED_IN" }); } else { @@ -101,7 +101,7 @@ async function refreshToken() { }); if (result) { sessionStorage.setItem('access_token', result.token.access_token); - sessionStorage.setItem('token_refresh_time', Date.now()); + sessionStorage.setItem('token_refresh_time', Date.now().toString()); // dispatch({ type: "USER_LOGGED_IN" }); } else { // dispatch({ type: "LOGIN_FAILURE" }); @@ -113,7 +113,7 @@ async function refreshToken() { } async function signOut() { - await ikcClient.logout({}); + await ikcClient.logout(); sessionStorage.removeItem('user_id'); sessionStorage.removeItem('access_token'); sessionStorage.removeItem('refresh_token'); diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000000000000000000000000000000000000..236acc5804dc20022e3b4d769204a2cf81f67c06 --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,22 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import Backend from 'i18next-http-backend'; + +i18n + .use(Backend) + .use(initReactI18next) + .init({ + lng: 'fr', + fallbackLng: 'fr', + ns: 'common', + defaultNS: 'common', + debug: true, + load: 'languageOnly', + loadPath: 'locales/{{lng}}/{{ns}}.json', + interpolation: { + // not needed for react as it escapes by default + escapeValue: false, + }, + }); + +export default i18n; diff --git a/src/index.js b/src/index.js index ab31dbff2807f9889d87325b74923ae2ce76a431..285bd62b697c75b71cb71332d329aa4a2f62f677 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,11 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import ReactDOM from 'react-dom'; import '@elastic/eui/dist/eui_theme_light.css'; import { UserProvider, checkUserLogin } from './context/UserContext'; import App from './App'; import { getLoginUrl, getUrlParam, redirect } from './Utils.js'; +import './i18n'; +import Loading from './components/Loading'; const userId = getUrlParam('kcId', ''); const accessToken = getUrlParam('accessToken', ''); @@ -16,7 +18,9 @@ checkUserLogin(userId, accessToken, refreshToken); if (sessionStorage.getItem('access_token')) { ReactDOM.render( <UserProvider> - <App userId={userId} accessToken={accessToken} refreshToken={refreshToken} /> + <Suspense fallback={<Loading />}> + <App userId={userId} accessToken={accessToken} refreshToken={refreshToken} /> + </Suspense> </UserProvider>, document.getElementById('root') ); diff --git a/src/pages/home/Home.js b/src/pages/home/Home.js index c54f9082634e36a5f12a16691c28c76b8dd5efc5..1be5b26d3dc9ecbb638f16d2ee895aedd5a85722 100644 --- a/src/pages/home/Home.js +++ b/src/pages/home/Home.js @@ -1,58 +1,34 @@ import React from 'react'; import { - EuiPageContent, EuiPageContentHeader, EuiPageContentHeaderSection, - EuiPageContentBody, EuiTitle, } from '@elastic/eui'; +import { useTranslation } from 'react-i18next'; const Home = () => { + const { t } = useTranslation('home'); + return ( - <> - <EuiPageContent> - <EuiPageContentHeader> - <EuiPageContentHeaderSection> - <EuiTitle> - <h2>Welcome to the IN-SYLVA IS application search module</h2> - </EuiTitle> - <br /> - <br /> - <p> - As a reminder, it should be remembered that the metadata stored in IN-SYLVA - IS are structured around the IN-SYLVA standard. - </p> - <br /> - <p> - This standard is composed of metadata fields. A metadata record is therefore - made up of a series of fields accompanied by their value. - </p> - <br /> - <br /> - <p> - With this part of the interface you will be able to search for metadata - records (previously loaded via the portal), by defining a certain number of - criteria. - </p> - <br /> - <p> - By default the "search" interface opens to a "plain text" search, ie the - records returned in the result are those which, in one of the field values, - contains the supplied character string. - </p> - <br /> - <p> - A click on the Advanced search button gives access to a more complete form - via which you can do more precise searches on one or more targeted fields. - </p> - <br /> - <br /> - <p>Click on the "Search" tab to access the search interface.</p> - </EuiPageContentHeaderSection> - </EuiPageContentHeader> - <EuiPageContentBody></EuiPageContentBody> - </EuiPageContent> - </> + <EuiPageContentHeader> + <EuiPageContentHeaderSection> + <EuiTitle> + <h2>{t('pageTitle')}</h2> + </EuiTitle> + <br /> + <p>{t('searchToolDescription.part1')}</p> + <br /> + <p>{t('searchToolDescription.part2')}</p> + <br /> + <p>{t('searchToolDescription.part3')}</p> + <br /> + <p>{t('searchToolDescription.part4')}</p> + <br /> + <p>{t('searchToolDescription.part5')}</p> + <br /> + <p>{t('searchToolDescription.part6')}</p> + </EuiPageContentHeaderSection> + </EuiPageContentHeader> ); }; diff --git a/src/pages/maps/SearchMap.js b/src/pages/maps/SearchMap.js index 958af49b1d74cea9ea376f9a618d1ccd98ef792f..5784009df60149119180f5bc09ad45b820bd95f6 100644 --- a/src/pages/maps/SearchMap.js +++ b/src/pages/maps/SearchMap.js @@ -1,479 +1,748 @@ -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Map, View } from 'ol'; -// import TileLayer from "ol/layer/Tile"; import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer'; -import ImageLayer from 'ol/layer/Image'; +import { Fill, Stroke, Style, Text } from 'ol/style'; import SourceOSM from 'ol/source/OSM'; import BingMaps from 'ol/source/BingMaps'; import { Vector as VectorSource } from 'ol/source'; import WMTS from 'ol/source/WMTS'; import WMTSTileGrid from 'ol/tilegrid/WMTS'; -//import {fromLonLat, get as getProjection} from 'ol/proj.js'; import { getWidth } from 'ol/extent'; -import ImageWMS from 'ol/source/ImageWMS'; +import { platformModifierKeyOnly } from 'ol/events/condition'; import GeoJSON from 'ol/format/GeoJSON'; -//import { Circle as CircleStyle, Fill, Stroke, Style, Text, Icon } from 'ol/style'; -import { Fill, Stroke, Style, Text, Icon } from 'ol/style'; -import { Circle, Point, Polygon } from 'ol/geom'; +import { defaults, DragBox, MouseWheelZoom, Select } from 'ol/interaction'; +import { Circle, Point } from 'ol/geom'; import Feature from 'ol/Feature'; -import * as proj from 'ol/proj'; import { - ScaleLine, - MousePosition, - OverviewMap, defaults as defaultControls, + FullScreen, + OverviewMap, + ScaleLine, + Zoom, } from 'ol/control'; -import { toStringXY } from 'ol/coordinate'; import 'ol/ol.css'; -import { EuiCheckbox } from '@elastic/eui'; +import { + EuiButton, + EuiButtonGroup, + EuiCheckbox, + EuiComboBox, + EuiProgress, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; import { htmlIdGenerator } from '@elastic/eui/lib/services'; import { updateArrayElement } from '../../Utils.js'; +import { useTranslation } from 'react-i18next'; +import styles from './styles.js'; +import * as proj from 'ol/proj'; +import proj4 from 'proj4'; +import { register } from 'ol/proj/proj4'; -const SearchMap = (props) => { - /*var image = new CircleStyle({ - radius: 5, - fill: null, - stroke: new Stroke({ color: 'red', width: 1 }), - });*/ - const styles = { - Point: new Style({ - image: new Icon({ - anchor: [0.5, 46], - anchorXUnits: 'fraction', - anchorYUnits: 'pixels', - src: 'https://openlayers.org/en/v3.20.1/examples/data/icon.png', - }), - }), - /* 'Circle': new Style({ - image: new Circle({ - radius: 7, - fill: new Fill({ - color: 'green' - }), - stroke: new Stroke({ - color: 'blue', - width: 2 - }) - }) - }),*/ - Circle: new Style({ - stroke: new Stroke({ - color: 'blue', - width: 2, - }), - //radius: 1000, - fill: new Fill({ - color: 'rgba(0,0,255,0.3)', - }), - }), - /* 'LineString': new Style({ - stroke: new Stroke({ - color: 'green', - width: 1, - }), - }), - 'MultiLineString': new Style({ - stroke: new Stroke({ - color: 'green', - width: 1, - }), - }), - 'MultiPoint': new Style({ - image: image, - }), - 'MultiPolygon': new Style({ - stroke: new Stroke({ - color: 'yellow', - width: 1, - }), - fill: new Fill({ - color: 'rgba(255, 255, 0, 0.1)', - }), - }), - 'Polygon': new Style({ - stroke: new Stroke({ - color: 'blue', - lineDash: [4], - width: 3, - }), - fill: new Fill({ - color: 'rgba(0, 0, 255, 0.1)', - }), - }), - 'GeometryCollection': new Style({ - stroke: new Stroke({ - color: 'magenta', - width: 2, - }), - fill: new Fill({ - color: 'magenta', - }), - image: new CircleStyle({ - radius: 10, - fill: null, - stroke: new Stroke({ - color: 'magenta', - }), - }), - }), - 'Circle': new Style({ - stroke: new Stroke({ - color: 'red', - width: 2, - }), - fill: new Fill({ - color: 'rgba(255,0,0,0.2)', - }), - }), - bluecircle: { - width: 30, - height: 30, - border: "1px solid #088", - bordeRadius: "15", - backgroundColor: "#0ff", - opacity: 0.5, - zIndex: 9999 - }, */ - mapContainer: { - height: '80vh', - width: '60vw', - }, - layerTree: { - cursor: 'pointer', - }, - }; - const source = new SourceOSM(); - const overviewMapControl = new OverviewMap({ - layers: [ - new TileLayer({ - source: source, - }), - ], - }); - const [center, setCenter] = useState(proj.fromLonLat([2.5, 46.5])); - const [zoom, setZoom] = useState(6); - const styleFunction = function (feature) { - return styles[feature.getGeometry().getType()]; - }; - - /* const newPoint = { - 'type': 'Feature', - 'geometry': { - 'type': 'Point', - 'coordinates': proj.fromLonLat([6.37777777778, 43.1938888889]), - }, - 'properties': { - 'Site': 'TEST' - } - } - - const geojsonObject = { - 'type': 'FeatureCollection', - 'crs': { - 'type': 'name', - 'properties': { - 'name': 'EPSG:4326', - }, - }, - 'features': [ - // newPoint - ], - } */ - - const vectorSource = new VectorSource({ - /*features: new GeoJSON().readFeatures(geojsonObject),*/ - projection: 'EPSG:4326', - }); - - // vectorSource.addFeature(new Feature(new Polygon([[proj.fromLonLat([0, 45]), proj.fromLonLat([0, 50]), proj.fromLonLat([5, 50]), proj.fromLonLat([5, 45])]]))); - - const vectorLayer = new VectorLayer({ - name: 'query_results', - source: vectorSource, - style: styleFunction, - }); +const proj3857 = proj.get('EPSG:3857'); +proj4.defs( + 'EPSG:2154', + '+proj=lcc +lat_1=49 +lat_2=44 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs' +); +register(proj4); +const initResolutions = () => { const resolutions = []; - const matrixIds = []; - const proj3857 = proj.get('EPSG:3857'); const maxResolution = getWidth(proj3857.getExtent()) / 256; + for (let i = 0; i < 20; i++) { + resolutions[i] = maxResolution / Math.pow(2, i); + } + return resolutions; +}; +const initMatrixIds = () => { + const matrixIds = []; for (let i = 0; i < 20; i++) { matrixIds[i] = i.toString(); - resolutions[i] = maxResolution / Math.pow(2, i); } + return matrixIds; +}; - const tileGrid = new WMTSTileGrid({ - origin: [-20037508, 20037508], - resolutions: resolutions, - matrixIds: matrixIds, - }); +const pointBaseStyle = new Style({ + fill: new Fill({ + color: 'rgba(80, 200, 120, 0.6)', + }), + stroke: new Stroke({ + color: 'rgba(80, 200, 120, 0.8)', + width: 2, + }), + text: new Text({ + textAlign: 'center', + textBaseline: 'middle', + font: '12px Arial', + fill: new Fill({ + color: 'rgba(0, 0, 0, 1)', + }), + stroke: new Stroke({ + color: 'rgba(255, 255, 255, 1)', + width: 1, + }), + offsetX: 0, + offsetY: 0, + rotation: 0, + }), +}); +const SearchMap = ({ searchResults, selectedPointsIds, setSelectedPointsIds }) => { + const { t } = useTranslation('maps'); + // ref for handling zoomHelperText display + const timerRef = useRef(null); + const [isLoading, setIsLoading] = useState(false); + const [center, setCenter] = useState(proj.fromLonLat([2.5, 46.5])); + const [zoom, setZoom] = useState(6); + // Selection tool mode: false = select points ; true = unselect points. + const [selectionToolMode, setSelectionToolMode] = useState(false); + const filterOptions = [ + { label: t('maps:layersTable.queryResults'), value: 'ResRequete' }, + { label: t('maps:layersTable.regions'), value: 'regions' }, + { label: t('maps:layersTable.departments'), value: 'departments' }, + { label: t('maps:layersTable.sylvoEcoRegions'), value: 'sylvoEcoRegions' }, + ]; + const [selectedFilterOptions, setSelectedFilterOptions] = useState([filterOptions[0]]); + const [zoomHelperTextFeature, setZoomHelperTextFeature] = useState( + new Feature({ + geometry: new Point(center), + }) + ); + + const pointStyle = (feature, resolution) => { + // Change color if point is selected + if (feature.get('isSelected')) { + pointBaseStyle.getFill().setColor('rgba(120, 80, 200, 0.6)'); + pointBaseStyle.getStroke().setColor('rgba(120, 80, 200, 0.8)'); + } else { + pointBaseStyle.getFill().setColor('rgba(80, 200, 120, 0.6)'); + pointBaseStyle.getStroke().setColor('rgba(80, 200, 120, 0.8)'); + } + // Display label text if map is enough zoomed in + const zoomLevel = map.getView().getZoomForResolution(resolution); + const label = zoomLevel >= 9 ? feature.get('nom') : null; + pointBaseStyle.getText().setText(label); + return pointBaseStyle; + }; + + const mapFilters = { + ResRequete: new VectorLayer({ + name: 'ResRequete', + source: new VectorSource({}), + }), + selectedPointsLayer: new VectorLayer({ + name: 'selectedPointsSource', + source: new VectorSource({}), + visible: true, + }), + regions: new VectorLayer({ + name: 'regions', + source: new VectorSource({ + url: 'https://data.geopf.fr/wfs/ows?SERVICE=WFS&REQUEST=GetFeature&VERSION=2.0.0&typeName=ADMINEXPRESS-COG-CARTO.LATEST:region&outputFormat=application/json', + format: new GeoJSON(), + }), + style: styles.filterLayer, + }), + departments: new VectorLayer({ + name: 'departments', + source: new VectorSource({ + url: 'https://data.geopf.fr/wfs/ows?SERVICE=WFS&REQUEST=GetFeature&VERSION=2.0.0&typeName=ADMINEXPRESS-COG-CARTO.LATEST:departement&outputFormat=application/json', + format: new GeoJSON(), + }), + style: styles.filterLayer, + }), + sylvoEcoRegions: new VectorLayer({ + name: 'sylvoEcoRegions', + source: new VectorSource({ + url: 'https://w3.avignon.inrae.fr/geoserver/urfm/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=urfm:ser_l93&srs=EPSG:2154&outputFormat=application/json', + format: new GeoJSON(), + projection: 'EPSG:2154', + crossOrigin: 'anonymous', + }), + style: styles.filterLayer, + }), + }; + const sourceOSM = new SourceOSM(); const [mapLayers, setMapLayers] = useState([ new TileLayer({ name: 'osm-layer', - source: source, + visible: true, + source: sourceOSM, }), - /* Bing Aerial */ new TileLayer({ name: 'Bing Aerial', + visible: false, preload: Infinity, source: new BingMaps({ key: 'AtdZQap9X-lowJjvdPhTgr1BctJuGGm-ZoVw9wO6dHt1VDURjRKEkssetwOe31Xt', imagerySet: 'Aerial', }), - //visible: false }), new TileLayer({ name: 'IGN', + visible: false, source: new WMTS({ url: 'https://wxs.ign.fr/choisirgeoportail/geoportail/wmts', layer: 'GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2', matrixSet: 'PM', format: 'image/png', - projection: 'EPSG:3857', - tileGrid: tileGrid, + projection: proj3857, + tileGrid: new WMTSTileGrid({ + origin: [-20037508, 20037508], + resolutions: initResolutions(), + matrixIds: initMatrixIds(), + }), style: 'normal', attributions: '<a href="https://www.ign.fr/" target="_blank">' + - '<img src="https://wxs.ign.fr/static/logos/IGN/IGN.gif" title="Institut national de l\'' + - 'information géographique et forestière" alt="IGN"></a>', + '<img src="https://www.ign.fr/files/default/styles/thumbnail/public/2020-06/logoIGN_300x200.png?itok=V80_0fm-" title="Institut national de l\'information géographique et forestière" alt="IGN"></a>', + }), + }), + new VectorLayer({ + name: 'queryResults', + visible: true, + source: new VectorSource({}), + }), + mapFilters['selectedPointsLayer'], + new VectorLayer({ + name: 'zoomHelperText', + visible: false, + background: 'rgba(0, 0, 0, 0.5)', + source: new VectorSource({ + features: [zoomHelperTextFeature], }), }), - vectorLayer, - - // new ImageLayer({ - // name: 'donuts-insylva-layer', - // source: new ImageWMS({ - // url: 'http: //w3.avignon.inra.fr/geoserver/wms', - // params: { 'LAYERS': 'urfm:donut_view' }, - // ratio: 1 - // }) - // }) ]); - const [mapLayersVisibility, setMapLayersVisibility] = useState( - new Array(mapLayers.length).fill(true) + const getLayerIndex = useCallback( + (name) => { + let index = 0; + mapLayers.forEach((layer) => { + if (layer.get('name') === name) { + index = mapLayers.indexOf(layer); + } + }); + return index; + }, + [mapLayers] ); - // const posGreenwich = proj.fromLonLat([0, 51.47]); - // set initial map objects - const view = new View({ - center: center, - zoom: zoom, - }); + // Two layers are set to visible on page load + const initMapLayersVisibility = () => { + let initialMapLayersVisibility = new Array(mapLayers.length).fill(false); + initialMapLayersVisibility[getLayerIndex('osm-layer')] = true; + initialMapLayersVisibility[getLayerIndex('selectedPointsSource')] = true; + initialMapLayersVisibility[getLayerIndex('queryResults')] = true; + return initialMapLayersVisibility; + }; + const [mapLayersVisibility, setMapLayersVisibility] = useState( + initMapLayersVisibility() + ); const [map] = useState( new Map({ target: null, layers: mapLayers, - controls: defaultControls().extend([ - new MousePosition({ - projection: 'EPSG:4326', + interactions: defaults({ mouseWheelZoom: false }).extend([ + new MouseWheelZoom({ + condition: platformModifierKeyOnly, }), + ]), + controls: defaultControls({ + zoom: false, + }).extend([ + new FullScreen(), + new Zoom(), new ScaleLine(), - overviewMapControl, + new OverviewMap({ + layers: [ + new TileLayer({ + source: sourceOSM, + }), + ], + }), ]), - view: view, + view: new View({ + center: center, + zoom: zoom, + }), + }) + ); + + useEffect(() => { + const zoomHelperTextStyle = new Style({ + text: new Text({ + font: '24px Arial', + text: t('maps:layersTable.zoomHelperText'), + fill: new Fill({ + color: 'white', + }), + }), + }); + let tmpZoomHelperTextFeature = new Feature({ + geometry: new Point(center), + }); + tmpZoomHelperTextFeature.setStyle(zoomHelperTextStyle); + setZoomHelperTextFeature(tmpZoomHelperTextFeature); + }, [t, center]); + + useEffect(() => { + const zoomHelperTextSource = map + .getLayers() + .item(getLayerIndex('zoomHelperText')) + .getSource(); + zoomHelperTextSource.clear(); + zoomHelperTextSource.addFeature(zoomHelperTextFeature); + }, [zoomHelperTextFeature]); + + // a select interaction object to handle click + const select = new Select({ + style: styles.selectTool, + }); + const [selectedFeatures, setSelectedFeatures] = useState(select.getFeatures()); + const [polygonSource, setPolygonSource] = useState(new VectorSource({})); + const [dragBox, setDragBox] = useState( + new DragBox({ + condition: platformModifierKeyOnly, }) ); - const processData = (props) => { - if (props.searchResults) { - props.searchResults.forEach((result) => { + // Map configuration and search results processing + useEffect(() => { + map.setTarget('map'); + map.addInteraction(select); + map.on('loadstart', () => { + setIsLoading(true); + }); + map.on('loadend', () => { + setIsLoading(false); + }); + map.on('moveend', () => onMapMoveEndCallback()); + map.on('wheel', (e) => onMapWheelScrollCallback(e)); + map.on('pointermove', (e) => onMapPointerMoveCallback(e)); + processData(); + }, [map, selectedFeatures]); + + // Create a new dragBox instance when polygonSource changes + useEffect(() => { + setDragBox( + new DragBox({ + condition: platformModifierKeyOnly, + }) + ); + }, [polygonSource, selectionToolMode]); + + // Create event listeners for dragBox when it changes + useEffect(() => { + // Remove previous DragBox instances before adding the new one + map.getInteractions().forEach((interaction) => { + if (interaction?.box_?.element_?.className === 'ol-box ol-dragbox') { + map.removeInteraction(interaction); + } + }); + dragBox.on('boxstart', () => onBoxStartCallback()); + dragBox.on('boxend', () => onBoxEndCallback()); + map.addInteraction(dragBox); + }, [dragBox]); + + // Update state on filters selection + useEffect(() => { + const layers = map.getLayers(); + for (let i = 0; i < filterOptions.length; i++) { + for (let j = 0; j < layers.getLength(); j++) { + const mapLayer = map.getLayers().item(j); + const mapLayerName = mapLayer.get('name'); if ( - result.experimental_site.geo_point && - result.experimental_site.geo_point.longitude && - result.experimental_site.geo_point.latitude + selectedFilterOptions.length !== 0 && + mapLayerName === selectedFilterOptions[0].value ) { - //vectorSource.addFeature(new Feature(new Point(proj.fromLonLat([result.experimental_site.geo_point.longitude, result.experimental_site.geo_point.latitude])))) - //vectorSource.addFeature(new Feature(new Circle(toStringXY([result.experimental_site.geo_point.longitude, result.experimental_site.geo_point.latitude],1),10))) - const coord = [ - result.experimental_site.geo_point.longitude, - result.experimental_site.geo_point.latitude, - ]; - vectorSource.addFeature(new Feature(new Circle(proj.fromLonLat(coord), 1000))); - //vectorSource.addFeature(new Feature(new Circle([result.experimental_site.geo_point.longitude, result.experimental_site.geo_point.latitude],10))) + // If same filter as before is selected, do nothing + return; } - }); + if (filterOptions[i].value === mapLayerName) { + // Remove previously selected filter + map.removeLayer(mapLayer); + } + } + } + // If no filter selected, return + if (selectedFilterOptions.length === 0) { + return; + } + if (selectedFilterOptions[0].value === 'ResRequete') { + // Update polygonSource object for dragBox selection + setPolygonSource(map.getLayers().item(getLayerIndex('queryResults')).getSource()); + // Only toggle query results filter if already hidden + if (!mapLayersVisibility[getLayerIndex('queryResults')]) { + setLayerDisplay('queryResults', true); + } + } else { + // Update polygonSource object for dragBox selection + setPolygonSource(mapFilters[selectedFilterOptions[0].value].get('source')); + // Display newly selected filter + map.addLayer(mapFilters[selectedFilterOptions[0].value]); } + }, [selectedFilterOptions]); + + // pointData.id set to 0 means that the point is a selected point + const createPointFeature = (pointData, coordinates, radius, isSelected) => { + const pointFeature = new Feature({ + geometry: new Circle(coordinates, radius), + }); + pointFeature.set('id', pointData.id); + pointFeature.set('nom', pointData.name); + pointFeature.set('isSelected', isSelected); + pointFeature.setStyle(pointStyle); + return pointFeature; }; - // useEffect Hooks - // [] = component did mount - // set the initial map targets - useEffect(() => { - map.setTarget('map'); - map.on('moveend', () => { - setCenter(map.getView().getCenter()); - setZoom(map.getView().getZoom()); + // On new selection start, unselect features + const onBoxStartCallback = () => { + clearSelectedFeatures(); + setIsLoading(true); + }; + + // On new selection end, add all previously selected features + const onBoxEndCallback = () => { + let newSelectedPointsIds = selectedPointsIds; + const boxExtent = dragBox.getGeometry().getExtent(); + // if the extent crosses the antimeridian process each world separately + const worldExtent = map.getView().getProjection().getExtent(); + const worldWidth = getWidth(worldExtent); + const startWorld = Math.floor((boxExtent[0] - worldExtent[0]) / worldWidth); + const endWorld = Math.floor((boxExtent[2] - worldExtent[0]) / worldWidth); + for (let world = startWorld; world <= endWorld; ++world) { + const left = Math.max(boxExtent[0] - world * worldWidth, worldExtent[0]); + const right = Math.min(boxExtent[2] - world * worldWidth, worldExtent[2]); + const extent = [left, boxExtent[1], right, boxExtent[3]]; + // Retrieve features (points) from source (depending on selected filter). + // Only unselected points and contained in dragBox + const boxFeatures = polygonSource + .getFeaturesInExtent(extent) + .filter( + (feature) => + !selectedFeatures.getArray().includes(feature) && + feature.getGeometry().intersectsExtent(extent) + ); + const pointsFeatures = map + .getLayers() + .item(getLayerIndex('queryResults')) + .getSource() + .getFeatures(); + const selectedPointsSource = map + .getLayers() + .item(getLayerIndex('selectedPointsSource')) + .getSource(); + for (let polygon = 0; polygon < boxFeatures.length; polygon++) { + const polygonGeometry = boxFeatures[polygon].getGeometry(); + // If selection tool is in select mode + if (!selectionToolMode) { + for (let point = 0; point < pointsFeatures.length; point++) { + const pointGeom = pointsFeatures[point].getGeometry(); + const coords = pointGeom.getCenter(); + if (polygonGeometry.intersectsCoordinate(coords)) { + const pointName = pointsFeatures[point].get('nom'); + const pointId = pointsFeatures[point].get('id'); + if (!getSelectedPointsNames(selectedPointsSource).includes(pointName)) { + // Add new selected features + const pointFeature = createPointFeature( + { + id: 0, + name: pointName, + }, + coords, + 5000, + true + ); + selectedPointsSource.addFeature(pointFeature); + // Add point id to selected points Ids + newSelectedPointsIds.push(pointId); + } + } + } + // If selection tool is in unselect mode + } else { + const selectedPointsFeatures = selectedPointsSource.getFeatures(); + if (selectedPointsFeatures.length > 0) { + for (let point = 0; point < selectedPointsFeatures.length; point++) { + const pointGeom = selectedPointsFeatures[point].getGeometry(); + const coords = pointGeom.getCenter(); + if (polygonGeometry.intersectsCoordinate(coords)) { + // Remove previously selected features + selectedPointsSource.removeFeature(selectedPointsFeatures[point]); + newSelectedPointsIds = newSelectedPointsIds.splice( + newSelectedPointsIds.indexOf(selectedPointsFeatures[point].get('id')), + 1 + ); + } + } + } + } + } + if (!selectionToolMode) { + let tmpSelectedFeatures = selectedFeatures; + setSelectedFeatures(null); + tmpSelectedFeatures.extend(boxFeatures); + setSelectedFeatures(tmpSelectedFeatures); + } else { + clearSelectedFeatures(); + } + } + // Update selected features list + setSelectedPointsIds(newSelectedPointsIds); + setIsLoading(false); + }; + + const processData = () => { + if (!searchResults) { + return; + } + const resultPointsSource = map + .getLayers() + .item(getLayerIndex('queryResults')) + .getSource(); + // Create points from search results + searchResults.forEach((result) => { + const geoPoint = result.experimental_site.geo_point; + if (geoPoint && geoPoint.longitude && geoPoint.latitude) { + const pointFeature = createPointFeature( + { + id: result.id, + name: result?.resource?.identifier, + }, + proj.fromLonLat([geoPoint.longitude, geoPoint.latitude]), + 3500, + false + ); + resultPointsSource.addFeature(pointFeature); + } }); + const selectedPointsSource = mapFilters.selectedPointsLayer.getSource(); + // Create selected points from id list + selectedPointsIds.forEach((id) => { + const point = searchResults.find((result) => result.id === id); + if (point) { + const geoPoint = point.experimental_site.geo_point; + const pointName = point.resource.identifier; + if (geoPoint && geoPoint.longitude && geoPoint.latitude) { + if (!getSelectedPointsNames(selectedPointsSource).includes(pointName)) { + const pointFeature = createPointFeature( + { + id: 0, + name: pointName, + }, + proj.fromLonLat([geoPoint.longitude, geoPoint.latitude]), + 5000, + true + ); + selectedPointsSource.addFeature(pointFeature); + } + } + } + }); + }; - /* map.getCurrentScale = function () { - //var map = this.getMap(); - var map = this; - var view = map.getView(); - var resolution = view.getResolution(); - var units = map.getView().getProjection().getUnits(); - var dpi = 25.4 / 0.28; - var mpu = proj.METERS_PER_UNIT[units]; - var scale = resolution * mpu * 39.37 * dpi; - return scale; - - }; - map.getView().on('change:resolution', function(evt){ - - var divScale = 10;// to adjusting - var radius = map.getCurrentScale()/divScale; - Circle.getStyle().getGeometry().setRadius(radius); - }); */ - - // Basic overlay to show where i want the new center to be - /* const overlay = new Overlay({ - position: posGreenwich, - element: overlayRef.current, - positioning: "center-center", - stopEvent: false - }); - map.addOverlay(overlay); */ - map.getView().animate({ zoom: zoom }, { center: center }, { duration: 2000 }); - - processData(props); - // clean up upon component unmount - /* return () => { - map.setTarget(null); - }; */ - }, [props]); - - const getLayerIndex = (name) => { - let index = 0; - mapLayers.forEach((layer) => { - if (layer.get('name') === name) { - index = mapLayers.indexOf(layer); + const clearSelectedFeatures = () => { + let tmpSelectedFeatures = selectedFeatures; + if (tmpSelectedFeatures) { + tmpSelectedFeatures.clear(); + } + setSelectedFeatures(tmpSelectedFeatures); + }; + + const onMapMoveEndCallback = () => { + setCenter(map.getView().getCenter()); + setZoom(map.getView().getZoom()); + }; + + // Display helper text inside map on mouse wheel scroll + const onMapWheelScrollCallback = (e) => { + // Return if user is zooming correctly + if (e.originalEvent.ctrlKey) { + return; + } + if (!mapLayersVisibility['zoomHelperText']) { + setLayerDisplay('zoomHelperText', true); + timerRef.current = setTimeout(() => { + setLayerDisplay('zoomHelperText', false); + }, 1500); + } + }; + + // Clear timer ref on unmount + useEffect(() => { + return () => clearTimeout(timerRef.current); + }, []); + + // Display pointer cursor on feature hover + const onMapPointerMoveCallback = (e) => { + const pixel = map.getEventPixel(e.originalEvent); + const hit = map.hasFeatureAtPixel(pixel); + map.getViewport().style.cursor = hit ? 'pointer' : ''; + }; + + const toggleSelectionToolMode = () => { + setSelectionToolMode((prev) => !prev); + }; + + const onFilterSelectChange = (newSelectedOptions) => { + setSelectedFilterOptions(newSelectedOptions); + }; + + const getSelectedPointsNames = (source) => { + let featureNames = []; + source.getFeatures().forEach((feature) => { + const name = feature.get('nom'); + if (name) { + featureNames.push(name); } }); - return index; + return featureNames; }; - const toggleLayer = (name) => { - let updatedLayers = mapLayers; + const setLayerDisplay = (name, isShown) => { const layerIndex = getLayerIndex(name); - // let updatedLayer = updatedLayers[getLayerIndex(name)] - setMapLayersVisibility( - updateArrayElement( - mapLayersVisibility, - layerIndex, - !mapLayersVisibility[layerIndex] - ) - ); - updatedLayers[layerIndex].setVisible(!updatedLayers[layerIndex].getVisible()); + let updatedLayers = mapLayers; + setMapLayersVisibility(updateArrayElement(mapLayersVisibility, layerIndex, isShown)); + updatedLayers[layerIndex].setVisible(isShown); setMapLayers(updatedLayers); }; - // helpers - /* const btnAction = () => { - // when button is clicked, recentre map - // this does not work :( - setCenter(posGreenwich); - setZoom(6); - }; */ - // render + // Display selected points names + const SelectedPointsList = () => { + const selectedPointsLayer = map + .getLayers() + .item(getLayerIndex('selectedPointsSource')); + let selectedPointsList = <></>; + let selectedPoints = 0; + if (selectedPointsLayer) { + selectedPoints = getSelectedPointsNames(selectedPointsLayer.getSource()); + if (selectedPoints.length === 0) { + selectedPointsList = <p>{t('maps:selectedPointsList.empty')}</p>; + } else { + selectedPointsList = selectedPoints.map((pointName, index) => { + return <p key={index}>{pointName}</p>; + }); + } + } + + return ( + <div style={styles.selectedPointsListContainer}> + <EuiTitle size="s"> + <h3> + {t('maps:selectedPointsList.title')} ({selectedPoints.length}) + </h3> + </EuiTitle> + <EuiSpacer size="m" /> + <EuiText style={styles.selectedPointsList}>{selectedPointsList}</EuiText> + </div> + ); + }; + + const selectionToolOptions = [ + { + id: 'selectionToolButton__0', + label: t('maps:layersTable.selectionTool.select'), + }, + { + id: 'selectionToolButton__1', + label: t('maps:layersTable.selectionTool.unselect'), + }, + ]; + + const unselectPoints = () => { + // Empty point selection + clearSelectedFeatures(); + // Remove displayed points from layer's source + map.getLayers().item(getLayerIndex('selectedPointsSource')).getSource().clear(); + setSelectedPointsIds([]); + }; + return ( - <div> - <div id="map" style={styles.mapContainer}></div> - <div id="layertree"> - <br /> - <h5>Cliquez sur les couches pour modifier leur visibilité.</h5> - <br /> - <table> - <thead> - <tr> - <th>Fonds carto ou vecteur</th> - {/*<th align="center">Couches INSYLVA</th> - <th>Infos attributs</th>*/} - </tr> - </thead> - <tbody> - <tr> - <td> - <ul> - <li> - <EuiCheckbox - id={htmlIdGenerator()()} - label="Query result" - checked={mapLayersVisibility[getLayerIndex('query_results')]} - onChange={(e) => toggleLayer('query_results')} - /> - </li> - <li> - <EuiCheckbox - id={htmlIdGenerator()()} - label="Open Street Map" - checked={mapLayersVisibility[getLayerIndex('osm-layer')]} - onChange={(e) => toggleLayer('osm-layer')} - /> - </li> - <li> - <EuiCheckbox - id={htmlIdGenerator()()} - label="Bing Aerial" - checked={mapLayersVisibility[getLayerIndex('Bing Aerial')]} - onChange={(e) => toggleLayer('Bing Aerial')} - /> - </li> - <li> - <EuiCheckbox - id={htmlIdGenerator()()} - label="PLAN IGN" - checked={mapLayersVisibility[getLayerIndex('IGN')]} - onChange={(e) => toggleLayer('IGN')} - /> - </li> - {/*<li> - <EuiCheckbox - id={htmlIdGenerator()()} - label="Départements" - checked={mapLayersVisibility[getLayerIndex("dept-layer")]} - onChange={e => toggleLayer("dept-layer")} - /> - </li> - <li> - <EuiCheckbox - id={htmlIdGenerator()()} - label="Régions" - checked={mapLayersVisibility[getLayerIndex("regs-layer")]} - onChange={e => toggleLayer("regs-layer")} - /> - </li>*/} - </ul> - </td> - <td> - <div id="info"> </div> - </td> - </tr> - </tbody> - </table> + <> + <div style={styles.container}> + <div id="map" style={styles.mapContainer}></div> + <SelectedPointsList /> + </div> + <div style={styles.loadingContainer}> + {isLoading && <EuiProgress size="l" color="accent" />} </div> - {/*<div - style={styles.bluecircle} - ref={overlayRef} - id="overlay" - title="overlay" - />*/} - {/*<button - style={{ - position: "absolute", - right: 10, - top: 10, - backgroundColor: "white" - }} - onClick={() => { - btnAction(); - }} - > - CLICK - </button>*/} - </div> + <EuiSpacer size={'m'} /> + <table style={styles.layersTable}> + <thead> + <tr> + <th>{t('maps:layersTableHeaders.cartography')}</th> + <th>{t('maps:layersTableHeaders.filters')}</th> + <th>{t('maps:layersTableHeaders.tools')}</th> + </tr> + </thead> + <tbody> + <tr> + <td style={styles.layersTableCells}> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:layersTable.openStreetMap')} + checked={mapLayersVisibility[getLayerIndex('osm-layer')]} + onChange={(e) => setLayerDisplay('osm-layer', e.target.checked)} + /> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:layersTable.bingAerial')} + checked={mapLayersVisibility[getLayerIndex('Bing Aerial')]} + onChange={(e) => setLayerDisplay('Bing Aerial', e.target.checked)} + /> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:layersTable.IGN')} + checked={mapLayersVisibility[getLayerIndex('IGN')]} + onChange={(e) => setLayerDisplay('IGN', e.target.checked)} + /> + </td> + <td style={styles.layersTableCells}> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:layersTable.queryResults')} + checked={mapLayersVisibility[getLayerIndex('queryResults')]} + onChange={(e) => setLayerDisplay('queryResults', e.target.checked)} + /> + <br /> + <EuiComboBox + aria-label={t('maps:layersTable.selectFilterOption')} + placeholder={t('maps:layersTable.selectFilterOption')} + singleSelection={{ asPlainText: true }} + options={filterOptions} + selectedOptions={selectedFilterOptions} + onChange={onFilterSelectChange} + styles={styles.filtersSelect} + /> + </td> + <td style={styles.layersTableCells}> + <EuiTitle size="xxs"> + <h6>{t('maps:layersTable.selectionTool.title')}</h6> + </EuiTitle> + <EuiButtonGroup + legend={t('maps:layersTable.selectionTool.title')} + options={selectionToolOptions} + onChange={toggleSelectionToolMode} + idSelected={ + selectionToolMode ? 'selectionToolButton__1' : 'selectionToolButton__0' + } + color={'primary'} + isFullWidth + /> + <EuiSpacer size="s" /> + <EuiButton + onClick={() => unselectPoints()} + style={styles.unselectAllButton} + > + {t('maps:layersTable.selectionTool.unselectAll')} + </EuiButton> + </td> + </tr> + </tbody> + </table> + </> ); }; diff --git a/src/pages/maps/styles.js b/src/pages/maps/styles.js new file mode 100644 index 0000000000000000000000000000000000000000..f1a31a2543a8ba9e6c428c1789e57eada06deabb --- /dev/null +++ b/src/pages/maps/styles.js @@ -0,0 +1,64 @@ +import { Fill, Stroke, Style } from 'ol/style'; + +const styles = { + filterLayer: new Style({ + fill: new Fill({ + color: 'rgba(128, 128, 128, 0.25)', + }), + stroke: new Stroke({ + width: 1, + lineDash: [4], + color: 'rgba(0, 0, 0, 0.5)', + }), + }), + selectTool: new Style({ + fill: new Fill({ + color: 'rgba(80, 200, 120, 0.6)', + }), + stroke: new Stroke({ + color: 'rgba(255, 255, 255, 0.7)', + width: 2, + }), + }), + container: { + width: '100%', + display: 'flex', + justifyContent: 'start', + minHeight: '60vh', + maxHeight: '60vh', + }, + mapContainer: { + minWidth: '60vw', + minHeight: '100%', + }, + loadingContainer: { + maxWidth: '60vw', + }, + selectedPointsListContainer: { + display: 'flex', + flexDirection: 'column', + width: '100%', + textAlign: 'center', + padding: '10px', + }, + selectedPointsList: { + overflow: 'auto', + maxHeight: '100%', + }, + layersTable: { + width: '60vw', + cursor: 'pointer', + marginTop: '10px', + }, + layersTableCells: { + padding: '10px', + }, + filtersSelect: { + maxWidth: '50%', + }, + unselectAllButton: { + minWidth: '100%', + }, +}; + +export default styles; diff --git a/src/pages/profile/Profile.js b/src/pages/profile/Profile.js index d6fecd338609b352e8e558aff61e4d54d576bb79..72c213856125829e5423fe6d0a2d51f9a2b18c61 100644 --- a/src/pages/profile/Profile.js +++ b/src/pages/profile/Profile.js @@ -1,6 +1,5 @@ import React, { useState, useEffect } from 'react'; import { - EuiPageContent, EuiPageContentHeader, EuiPageContentHeaderSection, EuiPageContentBody, @@ -23,20 +22,11 @@ import { createUserRequest, deleteUserRequest, } from '../../actions/user'; - -/* const fieldsGridOptions = { - filter: true, - filterType: "dropdown", - responsive: "stacked", - selectableRows: 'multiple', - selectableRowsOnClick: true, - onRowsSelect: (rowsSelected, allRows) => { - }, - onRowClick: (rowData, rowState) => { - }, -}; */ +import { useTranslation } from 'react-i18next'; +import styles from './styles'; const Profile = () => { + const { t } = useTranslation(['profile', 'common', 'validation']); const [user, setUser] = useState({}); const [userRole, setUserRole] = useState(''); const [groups, setGroups] = useState([]); @@ -47,15 +37,36 @@ const Profile = () => { const [valueError, setValueError] = useState(undefined); useEffect(() => { + const loadUser = () => { + if (sessionStorage.getItem('user_id')) { + findOneUser(sessionStorage.getItem('user_id')).then((user) => { + setUser(user); + }); + findOneUserWithGroupAndRole(sessionStorage.getItem('user_id')).then((result) => { + const userGroupList = userGroups; + result.forEach((user) => { + if (user.groupname) { + userGroupList.push({ + id: user.groupid, + label: user.groupname, + description: user.groupdescription, + }); + } + setUserRole(user.rolename); + }); + setUserGroups(userGroupList); + }); + } + }; loadUser(); getUserRequests(); getUserGroups(); getUserRoles(); - }, []); + }, [userGroups]); const groupColumns = [ - { field: 'label', name: 'Group Name', width: '30%' }, - { field: 'description', name: 'Group description' }, + { field: 'label', name: t('groups.groupName'), width: '30%' }, + { field: 'description', name: t('groups.groupDescription') }, ]; const getUserRoles = () => { @@ -98,8 +109,8 @@ const Profile = () => { const requestActions = [ { - name: 'Cancel', - description: 'Cancel this request', + name: t('common:validationActions.cancel'), + description: t('requestsList.cancelRequest'), icon: 'trash', type: 'icon', onClick: onDeleteRequest, @@ -107,33 +118,15 @@ const Profile = () => { ]; const requestsColumns = [ - { field: 'request_message', name: 'Message', width: '90%' }, - { field: 'is_processed', name: 'Processed' }, - { name: 'Delete', actions: requestActions }, + { + field: 'request_message', + name: t('requestsList.requestsMessage'), + width: '85%', + }, + { field: 'is_processed', name: t('requestsList.processed') }, + { name: t('common:validationActions.cancel'), actions: requestActions }, ]; - const loadUser = () => { - if (sessionStorage.getItem('user_id')) { - findOneUser(sessionStorage.getItem('user_id')).then((user) => { - setUser(user); - }); - findOneUserWithGroupAndRole(sessionStorage.getItem('user_id')).then((result) => { - const userGroupList = userGroups; - result.forEach((user) => { - if (user.groupname) { - userGroupList.push({ - id: user.groupid, - label: user.groupname, - description: user.groupdescription, - }); - } - setUserRole(user.rolename); - }); - setUserGroups(userGroupList); - }); - } - }; - const getUserGroupLabels = () => { let labelList = ''; if (!!userGroups) { @@ -155,111 +148,118 @@ const Profile = () => { ); }; + const onSendRoleRequest = () => { + if (selectedRole) { + const message = `The user ${user.username} (${user.email}) has made a request to get the role : ${selectedRole}.`; + createUserRequest(user.id, message); + sendMail('User role request', message); + alert(t('validation:requestSent')); + } + getUserRequests(); + }; + + const onSendGroupRequest = () => { + const groupList = []; + if (userGroups) { + userGroups.forEach((group) => { + groupList.push(group.label); + }); + const message = `The user ${user.username} (${user.email}) has made a request to be part of these groups : ${groupList}.`; + createUserRequest(user.id, message); + sendMail('User group request', message); + alert(t('validation:requestSent')); + } + getUserRequests(); + }; + return ( <> - <EuiPageContent> - <EuiPageContentHeader> - <EuiPageContentHeaderSection> - <EuiTitle> - <h2>Profile management</h2> - </EuiTitle> - </EuiPageContentHeaderSection> - </EuiPageContentHeader> - <EuiPageContentBody> - <EuiForm component="form"> - <EuiTitle size="s"> - <h3>Group list</h3> - </EuiTitle> - <EuiFormRow fullWidth label=""> - <EuiBasicTable items={groups} columns={groupColumns} /> - </EuiFormRow> - <EuiSpacer size="l" /> - <EuiTitle size="s"> - <h3>Requests list</h3> - </EuiTitle> - <EuiFormRow fullWidth label=""> - <EuiBasicTable items={userRequests} columns={requestsColumns} /> - </EuiFormRow> - <EuiSpacer size="l" /> - <EuiTitle size="s"> - <h3>Request group assignment modifications</h3> - </EuiTitle> - {getUserGroupLabels() ? ( - <p> - You currently belong to (or have a pending demand for) these groups :{' '} - {getUserGroupLabels()}{' '} - </p> - ) : ( - <p>You currently belong to no group</p> - )} - <EuiFormRow error={valueError} isInvalid={valueError !== undefined}> - <EuiComboBox - placeholder={'Select groups'} - options={groups} - selectedOptions={userGroups} - onChange={(selectedOptions) => { - setValueError(undefined); - setUserGroups(selectedOptions); - }} - onSearchChange={onValueSearchChange} - /> - </EuiFormRow> - <EuiSpacer size="m" /> - <EuiButton - onClick={() => { - if (userGroups) { - const groupList = []; - userGroups.forEach((group) => { - groupList.push(group.label); - }); - const message = `The user ${user.username} (${user.email}) has made a request to be part of these groups : ${groupList}.`; - createUserRequest(user.id, message); - sendMail('User group request', message); - alert('Your group request has been sent to the administrators.'); - } - getUserRequests(); + <EuiPageContentHeader> + <EuiPageContentHeaderSection> + <EuiTitle> + <h2>{t('pageTitle')}</h2> + </EuiTitle> + </EuiPageContentHeaderSection> + </EuiPageContentHeader> + <EuiPageContentBody> + <EuiForm component="form"> + <EuiTitle size="s"> + <h3>{t('groups.groupsList')}</h3> + </EuiTitle> + <EuiFormRow fullWidth label=""> + <EuiBasicTable items={groups} columns={groupColumns} /> + </EuiFormRow> + <EuiSpacer size="l" /> + <EuiTitle size="s"> + <h3>{t('requestsList.requestsList')}</h3> + </EuiTitle> + <EuiFormRow fullWidth label=""> + <EuiBasicTable items={userRequests} columns={requestsColumns} /> + </EuiFormRow> + <EuiSpacer size="l" /> + <EuiTitle size="s"> + <h3>{t('groupRequests.requestGroupAssignment')}</h3> + </EuiTitle> + {getUserGroupLabels() ? ( + <p + style={styles.currentRoleOrGroupText} + >{`${t('groupRequests.currentGroups')} ${getUserGroupLabels()}`}</p> + ) : ( + <p>{t('groupRequests.noGroup')}</p> + )} + <EuiFormRow error={valueError} isInvalid={valueError !== undefined}> + <EuiComboBox + placeholder={'Select groups'} + options={groups} + selectedOptions={userGroups} + onChange={(selectedOptions) => { + setValueError(undefined); + setUserGroups(selectedOptions); }} - fill - > - Send request - </EuiButton> - <EuiSpacer size="l" /> - <EuiTitle size="s"> - <h3>Request an application role</h3> - </EuiTitle> - {userRole ? ( - <p>Your current role is (or have a pending demand for) {userRole}</p> - ) : ( - <></> - )} - <EuiFormRow> - <EuiSelect - hasNoInitialSelection - options={roles} - value={selectedRole} - onChange={(e) => { - setSelectedRole(e.target.value); - }} - /> - </EuiFormRow> - <EuiSpacer size="m" /> - <EuiButton - onClick={() => { - if (selectedRole) { - const message = `The user ${user.username} (${user.email}) has made a request to get the role : ${selectedRole}.`; - createUserRequest(user.id, message); - sendMail('User role request', message); - alert('Your role request has been sent to the administrators.'); - } - getUserRequests(); + onSearchChange={onValueSearchChange} + /> + </EuiFormRow> + <EuiSpacer size="m" /> + <EuiButton + onClick={() => { + onSendGroupRequest(); + }} + fill + > + {t('common:validationActions.send')} + </EuiButton> + <EuiSpacer size="l" /> + <EuiTitle size="s"> + <h3>{t('roleRequests.requestRoleAssignment')}</h3> + </EuiTitle> + {userRole ? ( + <p + style={styles.currentRoleOrGroupText} + >{`${t('roleRequests.currentRole')} ${userRole}`}</p> + ) : ( + <></> + )} + <EuiFormRow> + <EuiSelect + hasNoInitialSelection + options={roles} + value={selectedRole} + onChange={(e) => { + setSelectedRole(e.target.value); }} - fill - > - Send request - </EuiButton> - </EuiForm> - </EuiPageContentBody> - </EuiPageContent> + /> + </EuiFormRow> + <EuiSpacer size="m" /> + <EuiButton + onClick={() => { + onSendRoleRequest(); + }} + fill + > + {t('common:validationActions.send')} + </EuiButton> + </EuiForm> + </EuiPageContentBody> </> ); }; diff --git a/src/pages/profile/styles.js b/src/pages/profile/styles.js new file mode 100644 index 0000000000000000000000000000000000000000..4c2776e98c63a6bf9e919a8f152504dccba201f4 --- /dev/null +++ b/src/pages/profile/styles.js @@ -0,0 +1,8 @@ +const style = { + currentRoleOrGroupText: { + marginTop: '10px', + marginBottom: '10px', + }, +}; + +export default style; diff --git a/src/pages/results/ResourceFlyout.js b/src/pages/results/ResourceFlyout.js new file mode 100644 index 0000000000000000000000000000000000000000..bd7522b49a5f5d3e9a26490d78ecd1b48b4d71f9 --- /dev/null +++ b/src/pages/results/ResourceFlyout.js @@ -0,0 +1,44 @@ +import React, { Fragment } from 'react'; +import { EuiFlyout, EuiFlyoutBody, EuiText } from '@elastic/eui'; +import { useTranslation } from 'react-i18next'; +import JsonView from '@in-sylva/json-view'; + +const ResourceFlyout = ({ + resourceFlyoutData, + setResourceFlyoutData, + isResourceFlyoutOpen, + setIsResourceFlyoutOpen, +}) => { + const { t } = useTranslation('results'); + + const closeResourceFlyout = () => { + setResourceFlyoutData({}); + setIsResourceFlyoutOpen(false); + }; + + return ( + isResourceFlyoutOpen && ( + <EuiFlyout + onClose={() => closeResourceFlyout()} + aria-labelledby={t('results:flyout.label')} + > + <EuiFlyoutBody> + <EuiText size="s"> + <Fragment> + <JsonView + src={resourceFlyoutData} + name={t('results:flyout.JSON.title')} + collapsed={true} + iconStyle={'triangle'} + enableClipboard={false} + displayDataTypes={false} + /> + </Fragment> + </EuiText> + </EuiFlyoutBody> + </EuiFlyout> + ) + ); +}; + +export default ResourceFlyout; diff --git a/src/pages/results/Results.js b/src/pages/results/Results.js index c54481e0a83b3d9be02a63e6c191c8544493ce56..3584081fabcbfa6d66e69d1b6a3f34996b4bd6ac 100644 --- a/src/pages/results/Results.js +++ b/src/pages/results/Results.js @@ -1,367 +1,38 @@ -import React, { Fragment, useEffect, useState } from 'react'; -import { - EuiButton, - EuiCallOut, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiIcon, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; -import { createTheme, MuiThemeProvider } from '@material-ui/core/styles'; -import MUIDataTable from 'mui-datatables'; -import JsonView from '@in-sylva/json-view'; -import { updateArrayElement } from '../../Utils.js'; - -const download = require('downloadjs'); - -const getMuiTheme = () => - createTheme({ - overrides: { - MUIDataTable: { - root: { - backgroundColor: '#fafbfc', - }, - paper: { - boxShadow: 'none', - }, - }, - }, - }); - -const changeFlyoutState = (array, index, value, defaultValue) => { - let newArray = new Array(array.length).fill(defaultValue); - newArray[index] = value; - return newArray; -}; - -const Results = (searchResults, search, basicSearch) => { - const [resultsCol, setResultsCol] = useState([]); - const [results, setResults] = useState([]); - const [isFlyoutOpen, setIsFlyoutOpen] = useState([false]); - const [searchQuery, setSearchQuery] = useState(''); - - useEffect(() => { - processData(searchResults); - search.length ? setSearchQuery(search) : setSearchQuery(basicSearch); - }, [searchResults, search, basicSearch]); - - const updateTableCell = (tableContent, value, colIndex, rowIndex) => { - const updatedRow = updateArrayElement(tableContent[rowIndex], colIndex, value); - return updateArrayElement(tableContent, rowIndex, updatedRow); - }; - - const closeAllFlyouts = (tableContent) => { - const updatedResults = []; - tableContent.forEach((tableRow, index) => { - updatedResults.push(updateArrayElement(tableRow, 0)); - }); - return updatedResults; - }; - - const recordFlyout = (record, recordIndex, isOpen) => { - if (isOpen) { - return ( - <> - <EuiFlyout - onClose={() => { - const updatedTable = updateTableCell( - closeAllFlyouts(results), - recordFlyout(record, recordIndex, false), - 0, - recordIndex - ); - const updatedArray = changeFlyoutState( - isFlyoutOpen, - recordIndex, - false, - false - ); - setIsFlyoutOpen(updatedArray); - setResults(updatedTable); - }} - aria-labelledby={recordIndex} - > - <EuiFlyoutBody> - <EuiText size="s"> - <Fragment> - <JsonView - name="In-Sylva metadata" - collapsed={true} - iconStyle={'triangle'} - src={record} - enableClipboard={false} - displayDataTypes={false} - /> - </Fragment> - </EuiText> - </EuiFlyoutBody> - </EuiFlyout> - <EuiIcon type="eye" color="danger" /> - </> - ); - } - }; - - const resultsGridOptions = { - print: false, - download: false, - filter: true, - filterType: 'dropdown', - responsive: 'standard', - selectableRows: 'none', - selectableRowsOnClick: false, - onRowSelectionChange: (rowsSelected, allRows) => {}, - onRowClick: (rowData, rowState) => {}, - onCellClick: (val, colMeta) => { - // if (searchResults.hits.hits && colMeta.colIndex !== 0) { - if (searchResults && colMeta.colIndex !== 0) { - // const updatedTable = updateTableCell(closeAllFlyouts(results), recordFlyout(searchResults.hits.hits[colMeta.rowIndex]._source, colMeta.rowIndex, !isFlyoutOpen[colMeta.rowIndex]), 0, colMeta.rowIndex) - const updatedTable = updateTableCell( - closeAllFlyouts(results), - recordFlyout( - searchResults[colMeta.dataIndex], - colMeta.dataIndex, - !isFlyoutOpen[colMeta.dataIndex] - ), - 0, - colMeta.dataIndex - ); - const updatedArray = changeFlyoutState( - isFlyoutOpen, - colMeta.dataIndex, - !isFlyoutOpen[colMeta.dataIndex], - false - ); - setIsFlyoutOpen(updatedArray); - setResults(updatedTable); - } - }, - }; - - /* const displayRecord = (record) => { - let recordDisplay = [] - if (!!record) { - const fields = Object.keys(record) - fields.forEach(field => { - if (typeof record[field] != 'string') { - // const rndId = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - if (isNaN(field)) { - // const buttonContent = `"${field}"` - let isStrArray = false - if (Array.isArray(record[field])) { - isStrArray = true - record[field].forEach(item => { - if (typeof item != 'string') - isStrArray = false - }) - } - if (isStrArray) { - recordDisplay.push( - <> - <h3> -  {field} - </h3> - {displayRecord(record[field])} - </> - ) - } else { - recordDisplay.push( - <> - <EuiSpacer size="s" /> - <EuiPanel paddingSize="s"> - <EuiAccordion id={Math.random().toString()} buttonContent={field}> - <EuiText size="s"> - {displayRecord(record[field])} - </EuiText> - </EuiAccordion> - </EuiPanel> - </> - ) - } - } else { - recordDisplay.push( - <> - {displayRecord(record[field])} - </> - ) - if (fields[fields.indexOf(field) + 1]) - recordDisplay.push( - <> - <EuiSpacer size="m" /> - <hr /> - </> - ) - } - } else { - if (isNaN(field)) { - recordDisplay.push( - <> - <h3> -  {field} - </h3> - <EuiTextColor color="secondary">  {record[field]}</EuiTextColor> - </> - ) - } else { - recordDisplay.push( - <> - <EuiSpacer size="s" /> - <EuiTextColor color="secondary">  {record[field]}</EuiTextColor> - </> - ) - } - } - }) - return recordDisplay - } - } - - const recordFlyout = (record, recordIndex, isFlyoutOpen, setIsFlyoutOpen) => { - let flyout - if (isFlyoutOpen[recordIndex]) { - // const flyOutContent = ReactHtmlParser(displayRecord(record, 1)) - const flyOutStr = displayRecord(record) - // const flyOutContent = parse(flyOutStr, { htmlparser2: { lowerCaseTags: false } }) - const flyout = ( - <> - <EuiFlyout - onClose={() => { - // setIsFlyoutOpen(updateArrayElement(isFlyoutOpen, recordIndex, false)) - // updateResultsCell(false, 0, recordIndex) - const updatedArray = changeFlyoutState(isFlyoutOpen, recordIndex, !isFlyoutOpen[recordIndex], false) - setIsFlyoutOpen(updatedArray) - }} - aria-labelledby={recordIndex}> - <EuiFlyoutBody> - <EuiText size="s"> - <Fragment> - {flyOutStr} - </Fragment> - </EuiText> - </EuiFlyoutBody> - </EuiFlyout> - <EuiIcon type='eye' color='danger' /> - </> - ); - return (flyout) - } - } */ - - /* const viewButton = (record, recordIndex, isFlyoutOpenIndex, isFlyoutOpen, setIsFlyoutOpen) => { - return ( - <> - <EuiButtonIcon - size="m" - color="success" - onClick={() => { - const flyOutArray = updateArrayElement(isFlyoutOpen, recordIndex, !isFlyoutOpen[recordIndex]) - setIsFlyoutOpen(flyOutArray) - updateResultsCell(!isFlyoutOpen[recordIndex], isFlyoutOpenIndex, recordIndex) - }} - iconType="eye" - title="View record" - /> - {recordFlyout(record, recordIndex, isFlyoutOpen, setIsFlyoutOpen)} - </> - ) - } */ - - const processData = (metadata) => { - // if (metadata && metadata.hits) { - if (metadata) { - const columns = []; - const rows = []; - // const metadataRecords = metadata.hits.hits - columns.push({ - name: 'currently open', - options: { - display: true, - viewColumns: true, - filter: true, - }, - }); - /* for (let recordIndex = 0; recordIndex < metadataRecords.length; recordIndex++) { - const row = [] - const displayedFields = metadataRecords[recordIndex]._source.resource - const flyoutCell = recordFlyout(metadataRecords[recordIndex]._source, recordIndex) */ - for (let recordIndex = 0; recordIndex < metadata.length; recordIndex++) { - const row = []; - const displayedFields = metadata[recordIndex].resource; - const flyoutCell = recordFlyout(metadata[recordIndex], recordIndex); - if (recordIndex >= isFlyoutOpen.length) { - setIsFlyoutOpen([...isFlyoutOpen, false]); - } - row.push(flyoutCell); - for (const fieldName in displayedFields) { - if (typeof displayedFields[fieldName] === 'string') { - if (recordIndex === 0) { - const column = { - name: fieldName, - options: { - display: true, - }, - }; - columns.push(column); - } - row.push(displayedFields[fieldName]); - } - } - rows.push(row); - } - setResultsCol(columns); - setResults(rows); - } - }; +import React, { useState } from 'react'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { useTranslation } from 'react-i18next'; +import ResultsTableMUI from './ResultsTableMUI'; +import ResourceFlyout from './ResourceFlyout'; +import ResultsDownload from './ResultsDownload'; + +const Results = ({ searchResults, searchQuery, selectedRowsIds, setSelectedRowsIds }) => { + const { t } = useTranslation('results'); + const [isResourceFlyoutOpen, setIsResourceFlyoutOpen] = useState(false); + const [resourceFlyoutData, setResourceFlyoutData] = useState({}); return ( <> - <EuiSpacer size="s" /> - <EuiFlexGroup justifyContent="spaceAround"> - <EuiSpacer size="s" /> - <EuiFlexItem grow={false}> - <EuiTitle size="xs"> - <h2>Your query : {searchQuery}</h2> - </EuiTitle> - </EuiFlexItem> - <EuiSpacer size="s" /> - </EuiFlexGroup> + <ResourceFlyout + resourceFlyoutData={resourceFlyoutData} + setResourceFlyoutData={setResourceFlyoutData} + isResourceFlyoutOpen={isResourceFlyoutOpen} + setIsResourceFlyoutOpen={setIsResourceFlyoutOpen} + /> <EuiFlexGroup> <EuiFlexItem> - <EuiCallOut - size="s" - title="Click on a line of the table to inspect resource metadata (except for the first column)." - iconType="search" - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - fill - onClick={() => { - if (searchResults) { - download( - `{"metadataRecords": ${JSON.stringify(searchResults, null, '\t')}}`, - 'InSylvaSearchResults.json', - 'application/json' - ); - } - }} - > - Download as JSON - </EuiButton> + <EuiCallOut size="s" title={t('results:clickOnRowTip')} iconType="search" /> </EuiFlexItem> + <ResultsDownload searchResults={searchResults} /> </EuiFlexGroup> - <MuiThemeProvider theme={getMuiTheme()}> - <MUIDataTable - title={'Search results'} - data={results} - columns={resultsCol} - options={resultsGridOptions} - /> - </MuiThemeProvider> + <EuiSpacer size={'m'} /> + <ResultsTableMUI + searchResults={searchResults} + searchQuery={searchQuery} + selectedRowsIds={selectedRowsIds} + setSelectedRowsIds={setSelectedRowsIds} + setResourceFlyoutData={setResourceFlyoutData} + setIsResourceFlyoutOpen={setIsResourceFlyoutOpen} + /> </> ); }; diff --git a/src/pages/results/ResultsDownload.js b/src/pages/results/ResultsDownload.js new file mode 100644 index 0000000000000000000000000000000000000000..7ed397818e778b6002540841f35743628c07c628 --- /dev/null +++ b/src/pages/results/ResultsDownload.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { EuiButton, EuiFlexItem } from '@elastic/eui'; +import download from 'downloadjs'; +import { useTranslation } from 'react-i18next'; + +const ResultsDownload = ({ searchResults }) => { + const { t } = useTranslation('results'); + + const downloadResults = () => { + if (!searchResults) { + return; + } + download( + `{"metadataRecords": ${JSON.stringify(searchResults, null, '\t')}}`, + 'InSylvaSearchResults.json', + 'application/json' + ); + }; + + return ( + <EuiFlexItem grow={false}> + <EuiButton fill onClick={() => downloadResults()}> + {t('results:downloadResultsButton.JSON')} + </EuiButton> + </EuiFlexItem> + ); +}; + +export default ResultsDownload; diff --git a/src/pages/results/ResultsTable.js b/src/pages/results/ResultsTable.js new file mode 100644 index 0000000000000000000000000000000000000000..415a67298ac34c8f3f2606e1be931f61205e4c3e --- /dev/null +++ b/src/pages/results/ResultsTable.js @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { EuiBasicTable } from '@elastic/eui'; + +const buildColumnName = (name) => { + name = name.split('.').join(' '); + name = name.charAt(0).toUpperCase() + name.slice(1); + return name; +}; + +const ResultsTable = ({ searchResults }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + function getValueFromPath(obj, path) { + return path.split('.').reduce((acc, part, i, arr) => { + if (Array.isArray(acc)) { + // The next part should be a key to match within array objects + const key = arr[i + 1]; + if (key) { + const found = acc.find((item) => item.type === key); + arr.splice(i + 1, 1); // Remove the key from the path array + return found ? found.value : undefined; + } + } + return acc && acc[part]; + }, obj); + } + + function getValues(obj, paths) { + return paths.reduce((acc, path) => { + acc[path] = getValueFromPath(obj, path); + return acc; + }, {}); + } + + const buildRows = (results, columns) => { + let dataRows = []; + results.forEach((result) => { + let row = getValues(result, columns); + dataRows.push(row); + }); + return dataRows; + }; + + const buildColumns = (userColumns) => { + let dataColumns = []; + dataColumns.push({ + field: 'isRowOpen', + name: 'Open', + sortable: false, + truncateText: true, + }); + userColumns.forEach((userColumn) => { + dataColumns.push({ + field: userColumn, + name: buildColumnName(userColumn), + sortable: true, + truncateText: true, + }); + }); + return dataColumns; + }; + + const buildSearchResultsTable = (results) => { + if (!results && results.length > 0) { + return; + } + // TODO: get columns settings from user to replace hard-coded array + const userColumns = [ + 'id', + 'resource.title', + 'resource.lineage', + 'resource.identifier', + 'resource.date.publication', + 'resource.date.revision', + 'resource.INSPIRE_type', + ]; + const columns = buildColumns(userColumns); + const rows = buildRows(results, userColumns); + return { + rows, + columns, + }; + }; + + const { rows, columns } = buildSearchResultsTable(searchResults); + + const paginationOptions = { + pageSizeOptions: [5, 10, 20, 0], + pageIndex, + pageSize, + totalItemCount: rows?.length, + }; + + const onTableChange = ({ page }) => { + if (page) { + setPageIndex(page.index); + setPageSize(page.size); + } + }; + + return ( + <EuiBasicTable + items={rows} + columns={columns} + pagination={paginationOptions} + onChange={onTableChange} + /> + ); +}; + +export default ResultsTable; diff --git a/src/pages/results/ResultsTableMUI.js b/src/pages/results/ResultsTableMUI.js new file mode 100644 index 0000000000000000000000000000000000000000..b28999146840743dd62dc1121d17a593a812b7cb --- /dev/null +++ b/src/pages/results/ResultsTableMUI.js @@ -0,0 +1,228 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import MUIDataTable from 'mui-datatables'; +import { createTheme, ThemeProvider } from '@material-ui/core'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +const getMuiTheme = () => + createTheme({ + overrides: { + MuiTableRow: { + root: { + '&:hover': { + cursor: 'pointer', + }, + }, + }, + }, + }); + +const ResultsTableMUI = ({ + searchResults, + searchQuery, + selectedRowsIds, + setSelectedRowsIds, + setIsResourceFlyoutOpen, + setResourceFlyoutData, +}) => { + const { t } = useTranslation('results'); + const [selectedRows, setSelectedRows] = useState([]); + + useEffect(() => { + setSelectedRows(selectedRowsIds.map((id) => getRowIdFromResourceData(id))); + }, [selectedRowsIds]); + + // Build each row in table from search results data + const buildRows = (results) => { + let dataRows = []; + if (results.length === 0) { + return dataRows; + } + results.forEach((result, index) => { + let row = { + id: result.id, + }; + for (const fieldName in result.resource) { + if (typeof result.resource[fieldName] === 'string') { + row[fieldName] = result.resource[fieldName]; + } + } + dataRows.push(row); + }); + return dataRows; + }; + + const buildColumnName = (name) => { + name = name.split('_').join(' '); + name = name.charAt(0).toUpperCase() + name.slice(1); + return name; + }; + + const buildColumns = (results) => { + if (!results[0]?.resource) { + return []; + } + let dataColumns = []; + dataColumns.push({ + name: 'id', + label: 'ID', + options: { + display: 'excluded', + }, + }); + // TODO: get columns settings from user to replace hard-coded array + for (const fieldName in results[0].resource) { + if (typeof results[0].resource[fieldName] === 'string') { + dataColumns.push({ + name: fieldName, + label: buildColumnName(fieldName), + options: { + display: true, + }, + }); + } + } + return dataColumns; + }; + + const buildResultsTable = (results) => { + if (!results && results.length > 0) { + return; + } + return { + rows: buildRows(results), + columns: buildColumns(results), + }; + }; + + const { rows, columns } = useMemo( + () => buildResultsTable(searchResults), + [searchResults] + ); + + const getResourceDataFromRowId = (id) => { + return searchResults.find((result) => result.id === id); + }; + + const getRowIdFromResourceData = (id) => { + for (let index = 0; index < rows.length; index++) { + if (rows[index].id === id) { + return index; + } + } + }; + + const onRowSelectionCallback = (selectedRow, allSelectedRows) => { + setSelectedRowsIds( + allSelectedRows.map((row) => { + return rows[row.dataIndex].id; + }) + ); + }; + + // Open resource flyout on row click (any cell) + const onCellClickCallback = (cellData, cellState) => { + if (!searchResults || searchResults.length === 0) { + return; + } + const resourceData = getResourceDataFromRowId(rows[cellState.dataIndex].id); + // Extract all values except for id to avoid displaying it to user + setResourceFlyoutData((({ id, ...rest }) => rest)(resourceData)); + setIsResourceFlyoutOpen(true); + }; + + const sortBySelectedRows = () => { + // TODO add sorting by row selection + /*return data.sort((a, b) => { + const isSelectedA = selectedRowsIds.includes(a.data[0]); + const isSelectedB = selectedRowsIds.includes(b.data[0]); + if (isSelectedA && !isSelectedB) { + return -1; + } else if (!isSelectedA && isSelectedB) { + return 1; + } else { + // If both rows are either selected or not, maintain their order + return 0; + } + });*/ + }; + + // Remove the delete rows button and display custom sort button + const CustomSelectToolbar = () => { + return ( + <EuiFlexGroup style={{ marginLeft: '10px' }}> + <EuiFlexItem grow={false}> + <EuiButton onClick={() => sortBySelectedRows()}> + {t('results:table.displaySelectedRowsButton')} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + ); + }; + + // Translations for mui-datatables component texts + const textLabels = { + body: { + noMatch: t('results:table.textLabels.body.noMatch'), + toolTip: t('results:table.textLabels.body.toolTip'), + }, + pagination: { + next: t('results:table.textLabels.pagination.next'), + previous: t('results:table.textLabels.pagination.previous'), + rowsPerPage: t('results:table.textLabels.pagination.rowsPerPage'), + displayRows: t('results:table.textLabels.pagination.displayRows'), + jumpToPage: t('results:table.textLabels.pagination.jumpToPage'), + }, + toolbar: { + search: t('results:table.textLabels.toolbar.search'), + viewColumns: t('results:table.textLabels.toolbar.viewColumns'), + filterTable: t('results:table.textLabels.toolbar.filterTable'), + }, + filter: { + all: t('results:table.textLabels.filter.all'), + title: t('results:table.textLabels.filter.title'), + reset: t('results:table.textLabels.filter.reset'), + }, + viewColumns: { + title: t('results:table.textLabels.viewColumns.title'), + titleAria: t('results:table.textLabels.viewColumns.titleAria'), + }, + selectedRows: { + text: t('results:table.textLabels.selectedRows.text'), + }, + }; + + const tableOptions = { + print: false, + download: false, + filter: true, + filterType: 'textField', + responsive: 'standard', + selectableRows: 'multiple', + selectableRowsOnClick: false, + rowsSelected: selectedRows, + rowsPerPage: 15, + rowsPerPageOptions: [15, 30, 50, 100], + jumpToPage: true, + searchPlaceholder: t('results:table.search'), + elevation: 0, // remove the boxShadow style + customToolbarSelect: () => <CustomSelectToolbar />, + selectToolbarPlacement: 'above', + onRowSelectionChange: onRowSelectionCallback, + onCellClick: onCellClickCallback, + textLabels, + }; + + return ( + <ThemeProvider theme={getMuiTheme()}> + <MUIDataTable + title={<Trans i18nKey={'results:table.title'} components={{ searchQuery }} />} + data={rows} + columns={columns} + options={tableOptions} + /> + </ThemeProvider> + ); +}; + +export default ResultsTableMUI; diff --git a/src/pages/search/AdvancedSearch/AdvancedSearch.js b/src/pages/search/AdvancedSearch/AdvancedSearch.js new file mode 100644 index 0000000000000000000000000000000000000000..ce4b0156c34e546d3a337cfd54136e9967fe9841 --- /dev/null +++ b/src/pages/search/AdvancedSearch/AdvancedSearch.js @@ -0,0 +1,1522 @@ +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiComboBox, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiGlobalToastList, + EuiHealth, + EuiIcon, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiPanel, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiProgress, + EuiRadioGroup, + EuiSelect, + EuiSpacer, + EuiSwitch, + EuiTextArea, + EuiTextColor, + EuiTitle, +} from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { + changeNameToLabel, + createAdvancedQueriesBySource, + getFieldsBySection, + getSections, + removeArrayElement, + SearchField, + updateArrayElement, + updateSearchFieldValues, +} from '../../../Utils'; +import { getQueryCount, searchQuery } from '../../../actions/source'; +import { DateOptions, NumericOptions, Operators } from '../Data'; +import TextField from '@material-ui/core/TextField'; +import { addUserHistory, fetchUserHistory } from '../../../actions/user'; +import { useTranslation } from 'react-i18next'; +import styles from './styles'; + +const updateSources = ( + searchFields, + sources, + setSelectedSources, + setAvailableSources +) => { + let updatedSources = []; + let availableSources = []; + let noPrivateField = true; + //search for policy fields to filter sources + searchFields.forEach((field) => { + if (field.isValidated) { + //if sources haven't already been filtered + if (noPrivateField && !updatedSources.length) { + availableSources = sources; + } else { + availableSources = updatedSources; + } + updatedSources = []; + field.sources.forEach((sourceId) => { + noPrivateField = false; + const source = availableSources.find((src) => src.id === sourceId); + if (source && !updatedSources.includes(source)) updatedSources.push(source); + }); + } + }); + setSelectedSources(updatedSources); + if (noPrivateField && !updatedSources.length) { + setAvailableSources(sources); + } else { + setAvailableSources(updatedSources); + } +}; + +const fieldValuesToString = (field) => { + let strValues = ''; + switch (field.type) { + case 'Numeric': + field.values.forEach((element) => { + switch (element.option) { + case 'between': + strValues = `${strValues} ${element.value1} <= ${field.name} <= ${element.value2} or `; + break; + default: + strValues = `${strValues} ${field.name} ${element.option} ${element.value1} or `; + } + }); + if (strValues.endsWith('or ')) + strValues = strValues.substring(0, strValues.length - 4); + break; + case 'Date': + field.values.forEach((element) => { + switch (element.option) { + case 'between': + strValues = `${strValues} ${element.startDate} <= ${field.name} <= ${element.endDate} or `; + break; + default: + strValues = `${strValues} ${field.name} ${element.option} ${element.startDate} or `; + } + }); + if (strValues.endsWith(' or ')) + strValues = strValues.substring(0, strValues.length - 4); + break; + case 'List': + strValues = `${strValues} ${field.name} = `; + field.values.forEach((element) => { + strValues = `${strValues} ${element.label}, `; + }); + if (strValues.endsWith(', ')) + strValues = strValues.substring(0, strValues.length - 2); + break; + //type : text + default: + strValues = `${strValues} ${field.name} = ${field.values}`; + } + return strValues; +}; + +const addHistory = ( + kcID, + search, + searchName, + searchFields, + searchDescription, + setUserHistory +) => { + addUserHistory( + sessionStorage.getItem('user_id'), + search, + searchName, + searchFields, + searchDescription + ).then(() => { + fetchHistory(setUserHistory); + }); +}; + +const fetchHistory = (setUserHistory) => { + fetchUserHistory(sessionStorage.getItem('user_id')).then((result) => { + if (result[0] && result[0].ui_structure) { + result.forEach((item) => { + item.ui_structure = JSON.parse(item.ui_structure); + item.label = `${item.name} - ${new Date(item.createdat).toLocaleString()}`; + }); + } + setUserHistory(result); + }); +}; + +const updateSearch = (setSearch, searchFields, selectedOperatorId, setSearchCount) => { + let searchText = ''; + searchFields.forEach((field) => { + if (field.isValidated) { + searchText = + searchText + + `{${fieldValuesToString(field)} } ${Operators[selectedOperatorId].value.toUpperCase()} `; + } + }); + if (searchText.endsWith(' AND ')) { + searchText = searchText.substring(0, searchText.length - 5); + } else if (searchText.endsWith(' OR ')) { + searchText = searchText.substring(0, searchText.length - 4); + } + setSearchCount(); + setSearch(searchText); +}; + +const HistorySelect = ({ + sources, + setAvailableSources, + setSelectedSources, + setSearch, + setSearchFields, + setSearchCount, + setFieldCount, + userHistory, + setUserHistory, +}) => { + const { t } = useTranslation('search'); + const [historySelectError, setHistorySelectError] = useState(undefined); + const [selectedSavedSearch, setSelectedSavedSearch] = useState(undefined); + + useEffect(() => { + fetchHistory(setUserHistory); + }, [setUserHistory]); + + const onHistoryChange = (selectedSavedSearch) => { + setHistorySelectError(undefined); + if (!!selectedSavedSearch[0].query) { + setSelectedSavedSearch(selectedSavedSearch); + setSearch(selectedSavedSearch[0].query); + setSearchCount(); + setFieldCount([]); + } + if (!!selectedSavedSearch[0].ui_structure) { + updateSources( + selectedSavedSearch[0].ui_structure, + sources, + setSelectedSources, + setAvailableSources + ); + setSearchFields(selectedSavedSearch[0].ui_structure); + } + }; + + const onHistorySearchChange = (value, hasMatchingOptions) => { + if (value.length === 0 || hasMatchingOptions) { + setHistorySelectError(undefined); + } else { + setHistorySelectError(t('search:advancedSearch.errorInvalidOption', { value })); + } + }; + + return ( + <> + {userHistory && Object.keys(userHistory).length !== 0 && ( + <EuiFormRow + error={historySelectError} + isInvalid={historySelectError !== undefined} + > + <EuiComboBox + placeholder={t('search:advancedSearch.searchHistory.placeholder')} + singleSelection={{ asPlainText: true }} + options={userHistory} + selectedOptions={selectedSavedSearch} + onChange={onHistoryChange} + onSearchChange={onHistorySearchChange} + /> + </EuiFormRow> + )} + </> + ); +}; + +const SearchBar = ({ + search, + setSearch, + setSearchResults, + searchFields, + setSearchFields, + selectedSources, + setSelectedSources, + availableSources, + setAvailableSources, + standardFields, + sources, + setSelectedTabNumber, + searchCount, + setSearchCount, + setFieldCount, + createEditableQueryToast, +}) => { + const { t } = useTranslation(['search', 'common']); + const [isLoading, setIsLoading] = useState(false); + const [userHistory, setUserHistory] = useState({}); + const [isSaveSearchModalOpen, setIsSaveSearchModalOpen] = useState(false); + const [searchDescription, setSearchDescription] = useState(''); + const [searchName, setSearchName] = useState(''); + const [readOnlyQuery, setReadOnlyQuery] = useState(true); + + const closeSaveSearchModal = () => { + setIsSaveSearchModalOpen(false); + }; + + const onClickAdvancedSearch = () => { + if (search.trim()) { + setIsLoading(true); + const queriesWithIndices = createAdvancedQueriesBySource( + standardFields, + search, + selectedSources, + availableSources + ); + searchQuery(queriesWithIndices).then((result) => { + setSearchResults(result); + setSelectedTabNumber(1); + if (isLoading) { + setIsLoading(false); + } + }); + } + }; + + const onClickCountResults = () => { + if (!!search) { + const queriesWithIndices = createAdvancedQueriesBySource( + standardFields, + search, + selectedSources, + availableSources + ); + getQueryCount(queriesWithIndices).then((result) => { + if (result || result === 0) { + setSearchCount(result); + } + }); + } + }; + + const onClickSaveSearch = () => { + if (!!searchName) { + addHistory( + sessionStorage.getItem('user_id'), + search, + searchName, + searchFields, + searchDescription, + setUserHistory + ); + setSearchName(''); + setSearchDescription(''); + closeSaveSearchModal(); + } + }; + + const SaveSearchModal = () => { + return ( + <EuiOverlayMask> + <EuiModal onClose={closeSaveSearchModal} initialFocus="[name=searchName]"> + <EuiModalHeader> + <EuiModalHeaderTitle> + {t('advancedSearch.searchHistory.saveSearch')} + </EuiModalHeaderTitle> + </EuiModalHeader> + <EuiModalBody> + <EuiForm> + <EuiFormRow label={t('advancedSearch.searchHistory.addSavedSearchName')}> + <EuiFieldText + name="searchName" + value={searchName} + onChange={(e) => setSearchName(e.target.value)} + /> + </EuiFormRow> + <EuiFormRow + label={t('advancedSearch.searchHistory.addSavedSearchDescription')} + > + <EuiTextArea + value={searchDescription} + onChange={(e) => setSearchDescription(e.target.value)} + placeholder={t( + 'advancedSearch.searchHistory.addSavedSearchDescriptionPlaceholder' + )} + fullWidth + compressed + /> + </EuiFormRow> + </EuiForm> + </EuiModalBody> + <EuiModalFooter> + <EuiButtonEmpty + onClick={() => { + closeSaveSearchModal(); + }} + > + {t('common:validationActions.cancel')} + </EuiButtonEmpty> + <EuiButton + onClick={() => { + onClickSaveSearch(); + }} + fill + > + {t('common:validationActions.save')} + </EuiButton> + </EuiModalFooter> + </EuiModal> + </EuiOverlayMask> + ); + }; + + return ( + <> + {isSaveSearchModalOpen && <SaveSearchModal />} + <EuiFlexGroup> + <EuiFlexItem> + <EuiTextArea + readOnly={readOnlyQuery} + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder={t('search:advancedSearch.textQueryPlaceholder')} + fullWidth + /> + {isLoading && ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiProgress postion="fixed" size="l" color="accent" /> + </EuiFlexItem> + </EuiFlexGroup> + )} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton + size="s" + fill + onClick={() => { + onClickAdvancedSearch(); + }} + > + {t('search:sendSearchButton')} + </EuiButton> + <EuiSpacer size="s" /> + {!isNaN(searchCount) && ( + <> + <EuiTextColor + color="secondary" + style={{ display: 'flex', justifyContent: 'center' }} + > + {t('search:advancedSearch.resultsCount', { count: searchCount })} + </EuiTextColor> + <EuiSpacer size="s" /> + </> + )} + <EuiButton + size="s" + onClick={() => { + onClickCountResults(); + }} + > + {t('search:advancedSearch.countResultsButton')} + </EuiButton> + <EuiSpacer size="s" /> + <EuiButton + size="s" + onClick={() => { + setIsSaveSearchModalOpen(true); + }} + > + {t('search:advancedSearch.searchHistory.saveSearch')} + </EuiButton> + <EuiSpacer size="s" /> + <EuiSwitch + compressed + label={t('search:advancedSearch.editableSearchButton')} + checked={!readOnlyQuery} + onChange={() => { + setReadOnlyQuery(!readOnlyQuery); + if (readOnlyQuery) { + createEditableQueryToast(); + } + }} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer size="s" /> + <EuiFlexGroup> + <EuiFlexItem> + <HistorySelect + sources={sources} + setAvailableSources={setAvailableSources} + setSelectedSources={setSelectedSources} + setSearch={setSearch} + setSearchFields={setSearchFields} + setSearchCount={setSearchCount} + setFieldCount={setFieldCount} + userHistory={userHistory} + setUserHistory={setUserHistory} + /> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +}; + +const PopoverSelect = ({ standardFields, searchFields, setSearchFields }) => { + const { t } = useTranslation('search'); + const [isPopoverSelectOpen, setIsPopoverSelectOpen] = useState(false); + const [selectedField, setSelectedField] = useState([]); + const [selectedSection, setSelectedSection] = useState([]); + + const handleAddField = () => { + if (!!selectedField[0]) { + const field = standardFields.find( + (item) => + item.field_name.replace(/_|\./g, ' ') === + selectedSection[0].label + ' ' + selectedField[0].label + ); + switch (field.field_type) { + case 'Text': + setSearchFields([ + ...searchFields, + new SearchField(field.field_name, field.field_type, '', false, field.sources), + ]); + break; + case 'List': + setSearchFields([ + ...searchFields, + new SearchField(field.field_name, field.field_type, [], false, field.sources), + ]); + break; + default: + setSearchFields([ + ...searchFields, + new SearchField( + field.field_name, + field.field_type, + [{}], + false, + field.sources + ), + ]); + } + } + }; + + const selectField = () => { + const renderOption = (option, searchValue, contentClassName) => { + const { label, color } = option; + return <EuiHealth color={color}>{label}</EuiHealth>; + }; + if (selectedSection.length) { + return ( + <> + <EuiComboBox + placeholder={t('search:advancedSearch.fields.addFieldPopover.title')} + singleSelection={{ asPlainText: true }} + options={getFieldsBySection(standardFields, selectedSection[0])} + selectedOptions={selectedField} + onChange={(selected) => setSelectedField(selected)} + isClearable={true} + renderOption={renderOption} + /> + <EuiPopoverFooter> + <EuiButton + size="s" + onClick={() => { + handleAddField(); + setIsPopoverSelectOpen(false); + setSelectedSection([]); + setSelectedField([]); + }} + > + {t('search:advancedSearch.fields.addFieldPopover.button')} + </EuiButton> + </EuiPopoverFooter> + </> + ); + } + }; + + return ( + <EuiPopover + panelPaddingSize="s" + button={ + <EuiButton + iconType="listAdd" + iconSide="left" + onClick={() => setIsPopoverSelectOpen(!isPopoverSelectOpen)} + > + {t('search:advancedSearch.fields.addFieldPopover.openPopoverButton')} + </EuiButton> + } + isOpen={isPopoverSelectOpen} + closePopover={() => setIsPopoverSelectOpen(false)} + > + <div style={{ width: 'intrinsic', minWidth: 240 }}> + <EuiPopoverTitle> + {t('search:advancedSearch.fields.addFieldPopover.title')} + </EuiPopoverTitle> + <EuiComboBox + placeholder={t('search:advancedSearch.fields.addFieldPopover.selectSection')} + singleSelection={{ asPlainText: true }} + options={getSections(standardFields)} + selectedOptions={selectedSection} + onChange={(selected) => { + setSelectedSection(selected); + setSelectedField([]); + }} + isClearable={false} + /> + </div> + {selectField()} + </EuiPopover> + ); +}; + +const PopoverValueContent = ({ + index, + standardFields, + searchFields, + setSearchFields, + setSearch, + setSearchCount, + fieldCount, + setFieldCount, + isPopoverValueOpen, + setIsPopoverValueOpen, + selectedOperatorId, + createPolicyToast, + selectedSources, + setSelectedSources, + availableSources, + setAvailableSources, +}) => { + const { t } = useTranslation(['search', 'common']); + const datePickerStyles = styles(); + const [valueError, setValueError] = useState(undefined); + + const onValueSearchChange = (value, hasMatchingOptions) => { + if (value.length === 0 || hasMatchingOptions) { + setValueError(undefined); + } else { + setValueError(t('search:advancedSearch.errorInvalidOption', { value })); + } + }; + + const validateFieldValues = () => { + let fieldValues; + if (Array.isArray(searchFields[index].values)) { + fieldValues = []; + searchFields[index].values.forEach((value) => { + if (!!value) { + fieldValues.push(value); + } + }); + } else { + fieldValues = searchFields[index].values; + } + + const updatedSearchFields = updateArrayElement( + searchFields, + index, + new SearchField( + searchFields[index].name, + searchFields[index].type, + fieldValues, + true, + searchFields[index].sources + ) + ); + setSearchFields(updatedSearchFields); + updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); + setFieldCount(updateArrayElement(fieldCount, index)); + if (searchFields[index].sources.length) { + const filteredSources = []; + searchFields[index].sources.forEach((sourceId) => { + let source; + if (selectedSources.length) { + source = selectedSources.find((src) => src.id === sourceId); + } else { + source = availableSources.find((src) => src.id === sourceId); + } + if (source) { + filteredSources.push(source); + } + }); + setAvailableSources(filteredSources); + setSelectedSources(filteredSources); + createPolicyToast(); + } + }; + + const invalidateFieldValues = () => { + const updatedSearchFields = updateArrayElement( + searchFields, + index, + new SearchField( + searchFields[index].name, + searchFields[index].type, + searchFields[index].values, + false, + searchFields[index].sources + ) + ); + setSearchFields(updatedSearchFields); + updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); + }; + + const ValuePopoverFooter = (i) => { + if (i === searchFields[index].values.length - 1) { + return ( + <EuiPopoverFooter> + <EuiButton + size="s" + onClick={() => { + setSearchFields( + updateArrayElement( + searchFields, + index, + new SearchField( + searchFields[index].name, + searchFields[index].type, + [...searchFields[index].values, {}], + false, + searchFields[index].sources + ) + ) + ); + }} + > + {t('search:advancedSearch.fields.fieldContentPopover.addValue')} + </EuiButton> + <EuiButton + size="s" + style={{ float: 'right' }} + onClick={() => { + validateFieldValues(); + setIsPopoverValueOpen(updateArrayElement(isPopoverValueOpen, index, false)); + }} + > + {t('common:validationActions.validate')} + </EuiButton> + </EuiPopoverFooter> + ); + } + }; + + const addFieldValue = (i, selectedOption) => { + setSearchFields( + updateSearchFieldValues( + searchFields, + index, + updateArrayElement(searchFields[index].values, i, { option: selectedOption }) + ) + ); + }; + + const getListFieldValues = () => { + const listFieldValues = []; + standardFields + .find((item) => item.field_name === searchFields[index].name) + .values.split(', ') + .sort() + .forEach((element) => { + listFieldValues.push({ label: element }); + }); + return listFieldValues; + }; + + switch (searchFields[index].type) { + case 'Text': + return ( + <> + <EuiFlexItem> + <EuiFieldText + placeholder={t( + 'search:advancedSearch.fields.fieldContentPopover.inputTextValue' + )} + value={searchFields[index].values} + onChange={(e) => + setSearchFields( + updateSearchFieldValues(searchFields, index, e.target.value) + ) + } + /> + </EuiFlexItem> + <EuiPopoverFooter> + <EuiButton + size="s" + style={{ float: 'right' }} + onClick={() => { + validateFieldValues(); + setIsPopoverValueOpen( + updateArrayElement(isPopoverValueOpen, index, false) + ); + }} + > + {t('common:validationActions.validate')} + </EuiButton> + </EuiPopoverFooter> + </> + ); + case 'List': + return ( + <> + <EuiFormRow error={valueError} isInvalid={valueError !== undefined}> + <EuiComboBox + placeholder={t( + 'search:advancedSearch.fields.fieldContentPopover.selectValues' + )} + options={getListFieldValues()} + selectedOptions={searchFields[index].values} + onChange={(selectedOptions) => { + setValueError(undefined); + setSearchFields( + updateSearchFieldValues(searchFields, index, selectedOptions) + ); + }} + onSearchChange={onValueSearchChange} + /> + </EuiFormRow> + <EuiPopoverFooter> + <EuiButton + size="s" + style={{ float: 'right' }} + onClick={() => { + validateFieldValues(); + setIsPopoverValueOpen( + updateArrayElement(isPopoverValueOpen, index, false) + ); + }} + > + {t('common:validationActions.validate')} + </EuiButton> + </EuiPopoverFooter> + </> + ); + case 'Numeric': + const NumericValues = (i) => { + if (!!searchFields[index].values[i].option) { + switch (searchFields[index].values[i].option) { + case 'between': + return ( + <> + <EuiFlexItem> + <EuiFieldText + placeholder={t( + 'search:advancedSearch.fields.fieldContentPopover.firstValue' + )} + value={searchFields[index].values[i].value1} + onChange={(e) => { + setSearchFields( + updateSearchFieldValues( + searchFields, + index, + updateArrayElement(searchFields[index].values, i, { + option: searchFields[index].values[i].option, + value1: e.target.value, + value2: searchFields[index].values[i].value2, + }) + ) + ); + }} + /> + </EuiFlexItem> + <EuiFlexItem> + <EuiFieldText + placeholder={t( + 'search:advancedSearch.fields.fieldContentPopover.secondValue' + )} + value={searchFields[index].values[i].value2} + onChange={(e) => + setSearchFields( + updateSearchFieldValues( + searchFields, + index, + updateArrayElement(searchFields[index].values, i, { + option: searchFields[index].values[i].option, + value1: searchFields[index].values[i].value1, + value2: e.target.value, + }) + ) + ) + } + /> + </EuiFlexItem> + {ValuePopoverFooter(i)} + </> + ); + + default: + return ( + <> + <EuiFlexItem> + <EuiFieldText + placeholder={t( + 'search:advancedSearch.fields.fieldContentPopover.inputTextValue' + )} + value={searchFields[index].values[i].value1} + onChange={(e) => { + setSearchFields( + updateSearchFieldValues( + searchFields, + index, + updateArrayElement(searchFields[index].values, i, { + option: searchFields[index].values[i].option, + value1: e.target.value, + value2: searchFields[index].values[i].value2, + }) + ) + ); + }} + /> + </EuiFlexItem> + {ValuePopoverFooter(i)} + </> + ); + } + } + }; + + return ( + <> + {searchFields[index].values.map((value, i) => ( + <div key={i}> + <EuiSelect + hasNoInitialSelection + id="Select an option" + options={NumericOptions} + value={searchFields[index].values[i].option} + onChange={(e) => { + addFieldValue(i, e.target.value); + invalidateFieldValues(); + }} + /> + {NumericValues(i)} + </div> + ))} + </> + ); + case 'Date': + const SelectDates = (i) => { + if (!!searchFields[index].values[i].option) { + switch (searchFields[index].values[i].option) { + case 'between': + return ( + <> + <form className={datePickerStyles.container} noValidate> + <TextField + label={t( + 'search:advancedSearch.fields.fieldContentPopover.betweenDate' + )} + type="date" + defaultValue={ + !!searchFields[index].values[i].startDate + ? searchFields[index].values[i].startDate + : Date.now() + } + className={datePickerStyles.textField} + InputLabelProps={{ + shrink: true, + }} + onChange={(e) => + setSearchFields( + updateSearchFieldValues( + searchFields, + index, + updateArrayElement(searchFields[index].values, i, { + option: searchFields[index].values[i].option, + startDate: e.target.value, + endDate: searchFields[index].values[i].endDate, + }) + ) + ) + } + /> + </form> + <form className={datePickerStyles.container} noValidate> + <TextField + label={t( + 'search:advancedSearch.fields.fieldContentPopover.andDate' + )} + type="date" + defaultValue={ + !!searchFields[index].values[i].endDate + ? searchFields[index].values[i].endDate + : Date.now() + } + className={datePickerStyles.textField} + InputLabelProps={{ + shrink: true, + }} + onChange={(e) => + setSearchFields( + updateSearchFieldValues( + searchFields, + index, + updateArrayElement(searchFields[index].values, i, { + option: searchFields[index].values[i].option, + startDate: searchFields[index].values[i].startDate, + endDate: e.target.value, + }) + ) + ) + } + /> + </form> + {ValuePopoverFooter(i)} + </> + ); + + default: + return ( + <> + <form className={datePickerStyles.container} noValidate> + <TextField + type="date" + defaultValue={ + !!searchFields[index].values[i].startDate + ? searchFields[index].values[i].startDate + : Date.now() + } + className={datePickerStyles.textField} + InputLabelProps={{ + shrink: true, + }} + onChange={(e) => + setSearchFields( + updateSearchFieldValues( + searchFields, + index, + updateArrayElement(searchFields[index].values, i, { + option: searchFields[index].values[i].option, + startDate: e.target.value, + endDate: Date.now(), + }) + ) + ) + } + /> + </form> + {ValuePopoverFooter(i)} + </> + ); + } + } + }; + + return ( + <> + {searchFields[index].values.map((value, i) => ( + <div key={i}> + <EuiSelect + hasNoInitialSelection + id="Select an option" + options={DateOptions} + value={searchFields[index].values[i].option} + onChange={(e) => { + addFieldValue(i, e.target.value); + invalidateFieldValues(); + }} + /> + {SelectDates(i)} + </div> + ))} + </> + ); + default: + } +}; + +const PopoverValueButton = ({ + index, + standardFields, + searchFields, + setSearchFields, + setSearch, + setSearchCount, + fieldCount, + setFieldCount, + selectedOperatorId, + createPolicyToast, + selectedSources, + setSelectedSources, + availableSources, + setAvailableSources, +}) => { + const { t } = useTranslation('search'); + const [isPopoverValueOpen, setIsPopoverValueOpen] = useState([false]); + + return ( + <EuiPopover + panelPaddingSize="s" + button={ + <EuiButtonIcon + size="s" + color="primary" + onClick={() => + setIsPopoverValueOpen( + updateArrayElement(isPopoverValueOpen, index, !isPopoverValueOpen[index]) + ) + } + iconType="documentEdit" + title={t('search:advancedSearch.fields.fieldContentPopover.addFieldValues')} + aria-label={t( + 'search:advancedSearch.fields.fieldContentPopover.addFieldValues' + )} + /> + } + isOpen={isPopoverValueOpen[index]} + closePopover={() => + setIsPopoverValueOpen(updateArrayElement(isPopoverValueOpen, index, false)) + } + > + <div style={{ width: 240 }}> + <PopoverValueContent + index={index} + standardFields={standardFields} + searchFields={searchFields} + setSearchFields={setSearchFields} + setSearch={setSearch} + setSearchCount={setSearchCount} + fieldCount={fieldCount} + setFieldCount={setFieldCount} + isPopoverValueOpen={isPopoverValueOpen} + setIsPopoverValueOpen={setIsPopoverValueOpen} + selectedOperatorId={selectedOperatorId} + createPolicyToast={createPolicyToast} + selectedSources={selectedSources} + setSelectedSources={setSelectedSources} + availableSources={availableSources} + setAvailableSources={setAvailableSources} + /> + </div> + </EuiPopover> + ); +}; + +const FieldsPanel = ({ + standardFields, + searchFields, + setSearchFields, + setSearch, + setSearchCount, + selectedOperatorId, + setSelectedOperatorId, + fieldCount, + setFieldCount, + availableSources, + setAvailableSources, + selectedSources, + setSelectedSources, + sources, + createPolicyToast, +}) => { + const { t } = useTranslation('search'); + + const countFieldValues = (field, index) => { + const fieldStr = `{${fieldValuesToString(field)}}`; + const queriesWithIndices = createAdvancedQueriesBySource( + standardFields, + fieldStr, + selectedSources, + availableSources + ); + getQueryCount(queriesWithIndices).then((result) => { + if (result || result === 0) + setFieldCount(updateArrayElement(fieldCount, index, result)); + }); + }; + + const handleRemoveField = (index) => { + const updatedSearchFields = removeArrayElement(searchFields, index); + setSearchFields(updatedSearchFields); + updateSources(updatedSearchFields, sources, setSelectedSources, setAvailableSources); + updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); + }; + + const handleClearValues = (index) => { + let updatedSearchFields; + switch (searchFields[index].type) { + case 'Text': + updatedSearchFields = updateArrayElement( + searchFields, + index, + new SearchField( + searchFields[index].name, + searchFields[index].type, + '', + false, + searchFields[index].sources + ) + ); + break; + case 'List': + updatedSearchFields = updateArrayElement( + searchFields, + index, + new SearchField( + searchFields[index].name, + searchFields[index].type, + [], + false, + searchFields[index].sources + ) + ); + break; + default: + updatedSearchFields = updateArrayElement( + searchFields, + index, + new SearchField( + searchFields[index].name, + searchFields[index].type, + [{}], + false, + searchFields[index].sources + ) + ); + } + setSearchFields(updatedSearchFields); + updateSources(updatedSearchFields, sources, setSelectedSources, setAvailableSources); + setFieldCount(updateArrayElement(fieldCount, index)); + updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); + }; + + if (standardFields === []) { + return <h2>{t('search:advancedSearch.fields.loadingFields')}</h2>; + } + + return ( + <> + <EuiTitle size="xs"> + <h2>{t('search:advancedSearch.fields.title')}</h2> + </EuiTitle> + <EuiPanel paddingSize="m"> + <EuiFlexGroup direction="column"> + {searchFields.map((field, index) => ( + <EuiPanel key={'field' + index} paddingSize="s"> + <EuiFlexItem grow={false}> + <EuiFlexGroup direction="row" alignItems="center"> + <EuiFlexItem grow={false}> + <EuiButtonIcon + size="s" + color="danger" + onClick={() => handleRemoveField(index)} + iconType="indexClose" + title={t('search:advancedSearch.fields.removeFieldButton')} + aria-label={t('search:advancedSearch.fields.removeFieldButton')} + /> + </EuiFlexItem> + <EuiFlexItem> + {field.isValidated ? ( + <> + {field.sources.length ? ( + <EuiHealth color="danger"> + {fieldValuesToString(field).replace(/_|\./g, ' ')} + </EuiHealth> + ) : ( + <EuiHealth color="primary"> + {fieldValuesToString(field).replace(/_|\./g, ' ')} + </EuiHealth> + )} + </> + ) : ( + <> + {field.sources.length ? ( + <EuiHealth color="danger"> + {field.name.replace(/_|\./g, ' ')} + </EuiHealth> + ) : ( + <EuiHealth color="primary"> + {field.name.replace(/_|\./g, ' ')} + </EuiHealth> + )} + </> + )} + </EuiFlexItem> + <EuiFlexItem grow={false}> + {!isNaN(fieldCount[index]) && ( + <EuiTextColor color="secondary"> + {t('search:advancedSearch.resultsCount', { + count: fieldCount[index], + })} + </EuiTextColor> + )} + </EuiFlexItem> + <EuiFlexItem grow={false}> + {field.isValidated && ( + <EuiButtonIcon + size="s" + onClick={() => countFieldValues(field, index)} + iconType="number" + title={t('search:advancedSearch.countResultsButton')} + aria-label={t('search:advancedSearch.countResultsButton')} + /> + )} + </EuiFlexItem> + <EuiFlexItem grow={false}> + {field.isValidated && ( + <EuiButtonIcon + size="s" + color="danger" + onClick={() => handleClearValues(index)} + iconType="trash" + title={t('search:advancedSearch.fields.clearValues')} + aria-label={t('search:advancedSearch.fields.clearValues')} + /> + )} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <PopoverValueButton + index={index} + standardFields={standardFields} + searchFields={searchFields} + setSearchFields={setSearchFields} + setSearch={setSearch} + setSearchCount={setSearchCount} + fieldCount={fieldCount} + setFieldCount={setFieldCount} + selectedOperatorId={selectedOperatorId} + createPolicyToast={createPolicyToast} + selectedSources={selectedSources} + setSelectedSources={setSelectedSources} + availableSources={availableSources} + setAvailableSources={setAvailableSources} + /> + </EuiFlexItem> + </EuiFlexGroup> + </EuiFlexItem> + </EuiPanel> + ))} + </EuiFlexGroup> + <EuiSpacer size="l" /> + <PopoverSelect + standardFields={standardFields} + searchFields={searchFields} + setSearchFields={setSearchFields} + /> + </EuiPanel> + <EuiSpacer size="s" /> + <EuiRadioGroup + options={Operators.map((operator) => { + return { ...operator, label: t(operator.label) }; + })} + idSelected={selectedOperatorId} + onChange={(id) => { + setSelectedOperatorId(id); + updateSearch(setSearch, searchFields, id, setSearchCount); + }} + name="operators group" + legend={{ + children: <span>{t('search:advancedSearch.searchOptions.title')}</span>, + }} + /> + </> + ); +}; + +const SourceSelect = ({ availableSources, selectedSources, setSelectedSources }) => { + const { t } = useTranslation('search'); + const [sourceSelectError, setSourceSelectError] = useState(undefined); + + if (Object.keys(availableSources).length === 0) { + return ( + <p> + <EuiIcon type="alert" color="danger" /> + </p> + ); + } + availableSources.forEach((source) => { + if (source.name) { + source = changeNameToLabel(source); + } + }); + + const onSourceChange = (selectedOptions) => { + setSourceSelectError(undefined); + setSelectedSources(selectedOptions); + }; + + const onSourceSearchChange = (value, hasMatchingOptions) => { + if (value.length === 0 || hasMatchingOptions) { + setSourceSelectError(undefined); + } else { + setSourceSelectError( + t('search:advancedSearch.errorInvalidOption', { value: value }) + ); + } + }; + + return ( + <> + <EuiTitle size="xs"> + <h2>{t('search:advancedSearch.partnerSources.title')}</h2> + </EuiTitle> + <EuiSpacer size="s" /> + <EuiFlexItem> + <EuiFormRow error={sourceSelectError} isInvalid={sourceSelectError !== undefined}> + <EuiComboBox + placeholder={t('search:advancedSearch.partnerSources.allSourcesSelected')} + options={availableSources} + selectedOptions={selectedSources} + onChange={onSourceChange} + onSearchChange={onSourceSearchChange} + /> + </EuiFormRow> + </EuiFlexItem> + </> + ); +}; + +const AdvancedSearch = ({ + search, + setSearch, + setSearchResults, + selectedSources, + setSelectedSources, + availableSources, + setAvailableSources, + standardFields, + setStandardFields, + sources, + setSelectedTabNumber, + setIsAdvancedSearch, + isAdvancedSearch, +}) => { + const { t } = useTranslation('search'); + const [notificationToasts, setNotificationToasts] = useState([]); + const [selectedOperatorId, setSelectedOperatorId] = useState('0'); + const [searchFields, setSearchFields] = useState([]); + const [fieldCount, setFieldCount] = useState([]); + const [searchCount, setSearchCount] = useState(); + + const createPolicyToast = () => { + const toast = { + title: t('search:advancedSearch.policyToast.title'), + color: 'warning', + iconType: 'alert', + toastLifeTimeMs: 15000, + text: ( + <> + <p>{t('search:advancedSearch.policyToast.content.0')}</p> + <p>{t('search:advancedSearch.policyToast.content.1')}</p> + <p>{t('search:advancedSearch.policyToast.content.2')}</p> + </> + ), + }; + setNotificationToasts(notificationToasts.concat(toast)); + }; + + const createEditableQueryToast = () => { + const toast = { + title: t('search:advancedSearch.policyToast.title'), + color: 'warning', + iconType: 'alert', + toastLifeTimeMs: 15000, + text: ( + <> + <p>{t('search:advancedSearch.editableQueryToast.content.part1')}</p> + <ul> + <li>{t('search:advancedSearch.editableQueryToast.content.part2')}</li> + <li>{t('search:advancedSearch.editableQueryToast.content.part3')}</li> + <li>{t('search:advancedSearch.editableQueryToast.content.part4')}</li> + </ul> + </> + ), + }; + setNotificationToasts(notificationToasts.concat(toast)); + }; + + const removeToast = (removedToast) => { + setNotificationToasts( + notificationToasts.filter((toast) => toast.id !== removedToast.id) + ); + }; + + return ( + <> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + onClick={() => { + setIsAdvancedSearch(!isAdvancedSearch); + }} + > + {t('search:advancedSearch.switchSearchMode')} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup> + <EuiFlexItem> + <EuiSpacer size="s" /> + <SearchBar + search={search} + setSearch={setSearch} + setSearchResults={setSearchResults} + searchFields={searchFields} + setSearchFields={setSearchFields} + selectedSources={selectedSources} + setSelectedSources={setSelectedSources} + availableSources={availableSources} + setAvailableSources={setAvailableSources} + standardFields={standardFields} + sources={sources} + setSelectedTabNumber={setSelectedTabNumber} + searchCount={searchCount} + setSearchCount={setSearchCount} + setFieldCount={setFieldCount} + createEditableQueryToast={createEditableQueryToast} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup> + <EuiFlexItem> + <EuiSpacer size="s" /> + <FieldsPanel + standardFields={standardFields} + setStandardFields={setStandardFields} + searchFields={searchFields} + setSearchFields={setSearchFields} + search={search} + setSearch={setSearch} + setSearchCount={setSearchCount} + selectedOperatorId={selectedOperatorId} + setSelectedOperatorId={setSelectedOperatorId} + fieldCount={fieldCount} + setFieldCount={setFieldCount} + availableSources={availableSources} + setAvailableSources={setAvailableSources} + selectedSources={selectedSources} + setSelectedSources={setSelectedSources} + sources={sources} + createPolicyToast={createPolicyToast} + /> + <EuiSpacer size="s" /> + <SourceSelect + availableSources={availableSources} + selectedSources={selectedSources} + setSelectedSources={setSelectedSources} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiGlobalToastList + toasts={notificationToasts} + dismissToast={removeToast} + toastLifeTimeMs={2500} + /> + </> + ); +}; + +export default AdvancedSearch; diff --git a/src/pages/search/AdvancedSearch/styles.js b/src/pages/search/AdvancedSearch/styles.js new file mode 100644 index 0000000000000000000000000000000000000000..66022dc748fea12084c48d6a7c6fc471b22dce14 --- /dev/null +++ b/src/pages/search/AdvancedSearch/styles.js @@ -0,0 +1,15 @@ +import { makeStyles } from '@material-ui/core/styles'; + +const style = makeStyles((theme) => ({ + container: { + display: 'flex', + flexWrap: 'wrap', + }, + textField: { + marginLeft: theme.spacing(1), + marginRight: theme.spacing(1), + width: 240, + }, +})); + +export default style; diff --git a/src/pages/search/BasicSearch/BasicSearch.js b/src/pages/search/BasicSearch/BasicSearch.js new file mode 100644 index 0000000000000000000000000000000000000000..397091ade583db73a62cd91f5e1353964949b301 --- /dev/null +++ b/src/pages/search/BasicSearch/BasicSearch.js @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiProgress, + EuiSpacer, +} from '@elastic/eui'; +import { createBasicQueriesBySource } from '../../../Utils'; +import { searchQuery } from '../../../actions/source'; +import { useTranslation } from 'react-i18next'; + +const BasicSearch = ({ + standardFields, + availableSources, + selectedSources, + basicSearch, + setBasicSearch, + setIsAdvancedSearch, + isAdvancedSearch, + setSearchResults, + setSelectedTabNumber, +}) => { + const { t } = useTranslation('search'); + const [isLoading, setIsLoading] = useState(false); + + const onFormSubmit = () => { + setIsLoading(true); + const queriesWithIndices = createBasicQueriesBySource( + standardFields, + basicSearch, + selectedSources, + availableSources + ); + searchQuery(queriesWithIndices).then((result) => { + setSearchResults(result); + if (isLoading) { + setIsLoading(false); + } + setSelectedTabNumber(1); + }); + }; + + return ( + <> + <EuiFlexGroup> + <EuiFlexItem grow={false}> + <EuiButtonEmpty + onClick={() => { + setIsAdvancedSearch(!isAdvancedSearch); + }} + > + {t('basicSearch.switchSearchMode')} + </EuiButtonEmpty> + </EuiFlexItem> + </EuiFlexGroup> + <EuiFlexGroup> + <EuiFlexItem> + <EuiSpacer size="s" /> + <form onSubmit={() => onFormSubmit()}> + <EuiFlexGroup> + <EuiFlexItem> + <EuiFieldSearch + value={basicSearch} + onChange={(e) => setBasicSearch(e.target.value)} + placeholder={t('basicSearch.searchInputPlaceholder')} + autoFocus={true} + fullWidth + /> + {isLoading && ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiProgress postion="fixed" size="l" color="accent" /> + </EuiFlexItem> + </EuiFlexGroup> + )} + </EuiFlexItem> + <EuiFlexItem grow={false}> + <EuiButton type="submit" fill isDisabled={isAdvancedSearch}> + {t('sendSearchButton')} + </EuiButton> + </EuiFlexItem> + </EuiFlexGroup> + </form> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +}; + +export default BasicSearch; diff --git a/src/pages/search/Data.js b/src/pages/search/Data.js index c6577648ea4029f5243b834d71fac2563982fbbb..d0a399a60e2e9dbe10a10c075a207501b35c6130 100644 --- a/src/pages/search/Data.js +++ b/src/pages/search/Data.js @@ -2,12 +2,12 @@ export const Operators = [ { id: '0', value: 'And', - label: 'Match all criterias', + label: 'search:advancedSearch.searchOptions.matchAll', }, { id: '1', value: 'Or', - label: 'Match at least one criteria', + label: 'search:advancedSearch.searchOptions.matchAtLeastOne', }, ]; diff --git a/src/pages/search/Search.js b/src/pages/search/Search.js index 4587f18b482ccf511ed4a59d83caf2bcf3a13cb9..5f92324661616fc050f13403cf4942cc00f3febb 100644 --- a/src/pages/search/Search.js +++ b/src/pages/search/Search.js @@ -1,1551 +1,35 @@ import React, { useState, useEffect } from 'react'; import { - EuiProgress, - EuiRadioGroup, - EuiFieldText, - EuiPanel, - EuiPopover, - EuiPopoverTitle, - EuiPopoverFooter, EuiTabbedContent, - EuiFormRow, - EuiComboBox, EuiPageContentBody, - EuiForm, - EuiTextArea, EuiFlexGroup, EuiFlexItem, - EuiFieldSearch, - EuiButton, - EuiButtonEmpty, - EuiSwitch, - EuiButtonIcon, - EuiIcon, EuiSpacer, - EuiPageContent, - EuiPageContentHeader, - EuiTitle, - EuiPageContentHeaderSection, - EuiTextColor, - EuiOverlayMask, - EuiModal, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, - EuiModalFooter, - EuiSelect, - EuiGlobalToastList, - EuiHealth, } from '@elastic/eui'; -import { makeStyles } from '@material-ui/core/styles'; -import TextField from '@material-ui/core/TextField'; -import { Operators, NumericOptions, DateOptions } from './Data'; import Results from '../results/Results'; import SearchMap from '../maps/SearchMap'; -import { - createBasicQueriesBySource, - changeNameToLabel, - SearchField, - removeNullFields, - getSections, - getFieldsBySection, - updateArrayElement, - removeArrayElement, - updateSearchFieldValues, - createAdvancedQueriesBySource, -} from '../../Utils.js'; +import { removeNullFields } from '../../Utils.js'; import { fetchPublicFields, fetchUserPolicyFields, fetchSources, - searchQuery, - getQueryCount, } from '../../actions/source'; -import { addUserHistory, fetchUserHistory } from '../../actions/user'; - -const useStyles = makeStyles((theme) => ({ - container: { - display: 'flex', - flexWrap: 'wrap', - }, - textField: { - marginLeft: theme.spacing(1), - marginRight: theme.spacing(1), - width: 240, - }, -})); - -const fieldValuesToString = (field) => { - let strValues = ''; - switch (field.type) { - case 'Numeric': - field.values.forEach((element) => { - switch (element.option) { - case 'between': - strValues = `${strValues} ${element.value1} <= ${field.name} <= ${element.value2} or `; - break; - default: - strValues = `${strValues} ${field.name} ${element.option} ${element.value1} or `; - } - }); - if (strValues.endsWith('or ')) - strValues = strValues.substring(0, strValues.length - 4); - break; - case 'Date': - field.values.forEach((element) => { - switch (element.option) { - case 'between': - strValues = `${strValues} ${element.startDate} <= ${field.name} <= ${element.endDate} or `; - break; - default: - strValues = `${strValues} ${field.name} ${element.option} ${element.startDate} or `; - } - }); - if (strValues.endsWith(' or ')) - strValues = strValues.substring(0, strValues.length - 4); - break; - case 'List': - strValues = `${strValues} ${field.name} = `; - field.values.forEach((element) => { - strValues = `${strValues} ${element.label}, `; - }); - if (strValues.endsWith(', ')) - strValues = strValues.substring(0, strValues.length - 2); - break; - - //type : text - default: - strValues = `${strValues} ${field.name} = ${field.values}`; - } - return strValues; -}; - -const updateSources = ( - searchFields, - sources, - setSelectedSources, - setAvailableSources -) => { - let updatedSources = []; - let availableSources = []; - let noPrivateField = true; - //search for policy fields to filter sources - searchFields.forEach((field) => { - if (field.isValidated) { - //if sources haven't already been filtered - if (noPrivateField && !updatedSources.length) { - availableSources = sources; - } else { - availableSources = updatedSources; - } - updatedSources = []; - field.sources.forEach((sourceId) => { - noPrivateField = false; - const source = availableSources.find((src) => src.id === sourceId); - if (source && !updatedSources.includes(source)) updatedSources.push(source); - }); - } - }); - setSelectedSources(updatedSources); - if (noPrivateField && !updatedSources.length) { - setAvailableSources(sources); - } else { - setAvailableSources(updatedSources); - } -}; - -const fetchHistory = (setUserHistory) => { - fetchUserHistory(sessionStorage.getItem('user_id')).then((result) => { - if (result[0] && result[0].ui_structure) { - result.forEach((item) => { - item.ui_structure = JSON.parse(item.ui_structure); - item.label = `${item.name} - ${new Date(item.createdat).toLocaleString()}`; - }); - } - setUserHistory(result); - }); -}; - -const addHistory = ( - kcID, - search, - searchName, - searchFields, - searchDescription, - setUserHistory -) => { - addUserHistory( - sessionStorage.getItem('user_id'), - search, - searchName, - searchFields, - searchDescription - ).then(() => { - fetchHistory(setUserHistory); - }); -}; - -const updateSearch = (setSearch, searchFields, selectedOperatorId, setSearchCount) => { - let searchText = ''; - searchFields.forEach((field) => { - if (field.isValidated) { - searchText = - searchText + - `{${fieldValuesToString(field)} } ${Operators[selectedOperatorId].value.toUpperCase()} `; - } - }); - if (searchText.endsWith(' AND ')) { - searchText = searchText.substring(0, searchText.length - 5); - } else if (searchText.endsWith(' OR ')) { - searchText = searchText.substring(0, searchText.length - 4); - } - setSearchCount(); - setSearch(searchText); -}; - -const HistorySelect = ( - sources, - setAvailableSources, - setSelectedSources, - setSearch, - searchFields, - selectedOperatorId, - userHistory, - setUserHistory, - setSearchFields, - setSearchCount, - setFieldCount, - selectedSavedSearch, - setSelectedSavedSearch, - historySelectError, - setHistorySelectError -) => { - if (Object.keys(userHistory).length !== 0) { - const onHistoryChange = (selectedSavedSearch) => { - setHistorySelectError(undefined); - if (!!selectedSavedSearch[0].query) { - setSelectedSavedSearch(selectedSavedSearch); - setSearch(selectedSavedSearch[0].query); - setSearchCount(); - setFieldCount([]); - } - if (!!selectedSavedSearch[0].ui_structure) { - updateSources( - selectedSavedSearch[0].ui_structure, - sources, - setSelectedSources, - setAvailableSources - ); - setSearchFields(selectedSavedSearch[0].ui_structure); - } - }; - - const onHistorySearchChange = (value, hasMatchingOptions) => { - setHistorySelectError( - value.length === 0 || hasMatchingOptions - ? undefined - : `"${value}" is not a valid option` - ); - }; - - return ( - <> - <EuiFormRow - error={historySelectError} - isInvalid={historySelectError !== undefined} - > - <EuiComboBox - placeholder="Load a previous search" - singleSelection={{ asPlainText: true }} - options={userHistory} - selectedOptions={selectedSavedSearch} - onChange={onHistoryChange} - onSearchChange={onHistorySearchChange} - /> - </EuiFormRow> - </> - ); - } -}; - -const SearchBar = ( - isLoading, - setIsLoading, - search, - setSearch, - searchResults, - setSearchResults, - searchFields, - setSearchFields, - searchName, - setSearchName, - searchDescription, - setSearchDescription, - readOnlyQuery, - setReadOnlyQuery, - selectedSources, - setSelectedSources, - availableSources, - setAvailableSources, - standardFields, - sources, - setSelectedTabNumber, - searchCount, - setSearchCount, - setFieldCount, - isReadOnlyModalOpen, - setIsReadOnlyModalOpen, - isSaveSearchModalOpen, - setIsSaveSearchModalOpen, - userHistory, - setUserHistory, - selectedSavedSearch, - setSelectedSavedSearch, - historySelectError, - setHistorySelectError, - selectedOperatorId, - createEditableQueryToast -) => { - // const closeReadOnlyModal = () => setIsReadOnlyModalOpen(false) - - /* const switchReadOnly = (readOnlyQuery, isReadOnlyModalOpen) => { - if (readOnlyQuery) { - setIsReadOnlyModalOpen(true) - } else { - setReadOnlyQuery(true) - } */ - /* if (!localStorage.getItem("InSylvaReadOnlySearch") && readOnlyQuery) { - setIsReadOnlyModalOpen(!isReadOnlyModalOpen) - } */ - // } - - /* let readOnlyModal; - - if (isReadOnlyModalOpen) { - readOnlyModal = ( - <EuiOverlayMask> - <EuiConfirmModal - title="Allow query editing" - onCancel={() => closeReadOnlyModal()} - onConfirm={() => { - setReadOnlyQuery(!readOnlyQuery) - closeReadOnlyModal() - }} - cancelButtonText="No" - confirmButtonText="Yes" - buttonColor="danger" - defaultFocusedButton="confirm"> - <p>Be aware that manually editing the query can spoil search results.</p> - <p>The syntax needs to be respected :</p> - <ul>Fields and their values must be given between brackets : { }</ul> - <ul>Check eventual typing mistakes</ul> - <ul>Make sure every opened bracket is properly closed</ul> - <p>Are you sure you want to do this?</p> - </EuiConfirmModal> - </EuiOverlayMask> - ) - }*/ - - const closeSaveSearchModal = () => setIsSaveSearchModalOpen(false); - - let saveSearchModal; - - if (isSaveSearchModalOpen) { - saveSearchModal = ( - <EuiOverlayMask> - <EuiModal onClose={closeSaveSearchModal} initialFocus="[name=searchName]"> - <EuiModalHeader> - <EuiModalHeaderTitle>Save search</EuiModalHeaderTitle> - </EuiModalHeader> - - <EuiModalBody> - <EuiForm> - <EuiFormRow label="Search name"> - <EuiFieldText - name="searchName" - value={searchName} - onChange={(e) => { - setSearchName(e.target.value); - }} - /> - </EuiFormRow> - <EuiFormRow label="Description (optional)"> - <EuiTextArea - value={searchDescription} - onChange={(e) => setSearchDescription(e.target.value)} - placeholder="Search description..." - fullWidth - compressed - /> - </EuiFormRow> - </EuiForm> - </EuiModalBody> - - <EuiModalFooter> - <EuiButtonEmpty - onClick={() => { - closeSaveSearchModal(); - }} - > - Cancel - </EuiButtonEmpty> - <EuiButton - onClick={() => { - if (!!searchName) { - addHistory( - sessionStorage.getItem('user_id'), - search, - searchName, - searchFields, - searchDescription, - setUserHistory - ); - setSearchName(''); - setSearchDescription(''); - closeSaveSearchModal(); - } - }} - fill - > - Save - </EuiButton> - </EuiModalFooter> - </EuiModal> - </EuiOverlayMask> - ); - } - - return ( - <> - {/*!readOnlyQuery ? - <> - <EuiCallOut title="Proceed with caution!" color="warning" iconType="alert"> - <p>Be aware that manually editing the query can spoil search results. The syntax must be respected :</p> - <ul>Fields and their values should be put between brackets : { } - Make sure every opened bracket is properly closed</ul> - <ul>"AND" and "OR" should be capitalized between different fields conditions and lowercased within a field expression</ul> - <ul>Make sure to check eventual typing mistakes</ul> - - </EuiCallOut> - <EuiSpacer size="s" /> - </> - : <></> - */} - <EuiFlexGroup> - <EuiFlexItem> - <EuiTextArea - readOnly={readOnlyQuery} - value={search} - onChange={(e) => setSearch(e.target.value)} - placeholder="Add fields..." - fullWidth - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - size="s" - fill - onClick={() => { - if (search.trim()) { - setIsLoading(true); - const queriesWithIndices = createAdvancedQueriesBySource( - standardFields, - search, - selectedSources, - availableSources - ); - searchQuery(queriesWithIndices).then((result) => { - // sessionStorage.setItem("searchResults", JSON.stringify(result)) - setSearchResults(result); - setSelectedTabNumber(1); - setIsLoading(false); - }); - } - }} - > - Search - </EuiButton> - <EuiSpacer size="s" /> - {isNaN(searchCount) ? ( - <></> - ) : ( - <> - <EuiTextColor - color="secondary" - style={{ display: 'flex', justifyContent: 'center' }} - > - {searchCount} {searchCount === 1 ? 'result' : 'results'} - </EuiTextColor> - <EuiSpacer size="s" /> - </> - )} - <EuiButton - size="s" - onClick={() => { - if (!!search) { - const queriesWithIndices = createAdvancedQueriesBySource( - standardFields, - search, - selectedSources, - availableSources - ); - getQueryCount(queriesWithIndices).then((result) => { - if (result || result === 0) setSearchCount(result); - }); - } - }} - > - Count results - </EuiButton> - <EuiSpacer size="s" /> - <EuiButton - size="s" - onClick={() => { - setIsSaveSearchModalOpen(true); - }} - > - Save search - </EuiButton> - {saveSearchModal} - <EuiSpacer size="s" /> - <EuiSwitch - compressed - label={'Editable'} - checked={!readOnlyQuery} - onChange={() => { - // switchReadOnly(readOnlyQuery, isReadOnlyModalOpen) - setReadOnlyQuery(!readOnlyQuery); - if (readOnlyQuery) { - createEditableQueryToast(); - } - }} - /> - {/* readOnlyModal */} - </EuiFlexItem> - </EuiFlexGroup> - {isLoading && ( - <EuiFlexGroup> - <EuiFlexItem> - <EuiProgress postion="fixed" size="l" color="accent" /> - </EuiFlexItem> - </EuiFlexGroup> - )} - <EuiSpacer size="s" /> - <EuiFlexGroup> - <EuiFlexItem> - {HistorySelect( - sources, - setAvailableSources, - setSelectedSources, - setSearch, - searchFields, - selectedOperatorId, - userHistory, - setUserHistory, - setSearchFields, - setSearchCount, - setFieldCount, - selectedSavedSearch, - setSelectedSavedSearch, - historySelectError, - setHistorySelectError - )} - </EuiFlexItem> - </EuiFlexGroup> - </> - ); -}; - -const PopoverSelect = ( - standardFields, - setStandardFields, - searchFields, - setSearchFields, - selectedField, - setSelectedField, - selectedSection, - setSelectedSection, - isPopoverSelectOpen, - setIsPopoverSelectOpen, - fieldCount, - setFieldCount, - selectedSources, - setSelectedSources -) => { - const handleAddfield = () => { - if (!!selectedField[0]) { - const field = standardFields.find( - (item) => - item.field_name.replace(/_|\./g, ' ') === - selectedSection[0].label + ' ' + selectedField[0].label - ); - switch (field.field_type) { - case 'Text': - setSearchFields([ - ...searchFields, - new SearchField(field.field_name, field.field_type, '', false, field.sources), - ]); - break; - case 'List': - setSearchFields([ - ...searchFields, - new SearchField(field.field_name, field.field_type, [], false, field.sources), - ]); - break; - default: - setSearchFields([ - ...searchFields, - new SearchField( - field.field_name, - field.field_type, - [{}], - false, - field.sources - ), - ]); - } - } - }; - - const selectField = () => { - const renderOption = (option, searchValue, contentClassName) => { - const { label, color } = option; - return <EuiHealth color={color}>{label}</EuiHealth>; - }; - if (selectedSection.length) { - return ( - <> - <EuiComboBox - placeholder="Select a field" - singleSelection={{ asPlainText: true }} - options={getFieldsBySection(standardFields, selectedSection[0])} - selectedOptions={selectedField} - onChange={(selected) => setSelectedField(selected)} - isClearable={true} - renderOption={renderOption} - /> - <EuiPopoverFooter> - <EuiButton - size="s" - onClick={() => { - handleAddfield(); - setIsPopoverSelectOpen(false); - setSelectedSection([]); - setSelectedField([]); - }} - > - Add this field - </EuiButton> - </EuiPopoverFooter> - </> - ); - } - }; - - return ( - <EuiPopover - panelPaddingSize="s" - button={ - <EuiButton - iconType="listAdd" - iconSide="left" - onClick={() => setIsPopoverSelectOpen(!isPopoverSelectOpen)} - > - Add field - </EuiButton> - } - isOpen={isPopoverSelectOpen} - closePopover={() => setIsPopoverSelectOpen(false)} - > - <div style={{ width: 'intrinsic', minWidth: 240 }}> - <EuiPopoverTitle>Select a field</EuiPopoverTitle> - <EuiComboBox - placeholder="Select a section" - singleSelection={{ asPlainText: true }} - options={getSections(standardFields)} - selectedOptions={selectedSection} - onChange={(selected) => { - setSelectedSection(selected); - setSelectedField([]); - }} - isClearable={false} - /> - </div> - {selectField()} - </EuiPopover> - ); -}; - -const PopoverValueContent = ( - index, - standardFields, - setStandardFields, - searchFields, - setSearchFields, - valueError, - setValueError, - search, - setSearch, - setSearchCount, - fieldCount, - setFieldCount, - isPopoverValueOpen, - setIsPopoverValueOpen, - selectedOperatorId, - datePickerStyles, - createPolicyToast, - selectedSources, - setSelectedSources, - availableSources, - setAvailableSources -) => { - const onValueSearchChange = (value, hasMatchingOptions) => { - setValueError( - value.length === 0 || hasMatchingOptions - ? undefined - : `"${value}" is not a valid option` - ); - }; - - const validateFieldValues = () => { - let fieldValues; - if (Array.isArray(searchFields[index].values)) { - fieldValues = []; - searchFields[index].values.forEach((value) => { - if (!!value) { - fieldValues.push(value); - } - }); - } else { - fieldValues = searchFields[index].values; - } - - const updatedSearchFields = updateArrayElement( - searchFields, - index, - new SearchField( - searchFields[index].name, - searchFields[index].type, - fieldValues, - true, - searchFields[index].sources - ) - ); - setSearchFields(updatedSearchFields); - updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); - setFieldCount(updateArrayElement(fieldCount, index)); - if (searchFields[index].sources.length) { - const filteredSources = []; - searchFields[index].sources.forEach((sourceId) => { - let source; - if (selectedSources.length) { - source = selectedSources.find((src) => src.id === sourceId); - } else { - source = availableSources.find((src) => src.id === sourceId); - } - if (source) { - filteredSources.push(source); - } - }); - setAvailableSources(filteredSources); - setSelectedSources(filteredSources); - createPolicyToast(); - } - }; - - const invalidateFieldValues = () => { - const updatedSearchFields = updateArrayElement( - searchFields, - index, - new SearchField( - searchFields[index].name, - searchFields[index].type, - searchFields[index].values, - false, - searchFields[index].sources - ) - ); - setSearchFields(updatedSearchFields); - updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); - }; - - const ValuePopoverFooter = (i) => { - if (i === searchFields[index].values.length - 1) { - return ( - <EuiPopoverFooter> - <EuiButton - size="s" - onClick={() => { - setSearchFields( - updateArrayElement( - searchFields, - index, - new SearchField( - searchFields[index].name, - searchFields[index].type, - [...searchFields[index].values, {}], - false, - searchFields[index].sources - ) - ) - ); - }} - > - Add value - </EuiButton> - <EuiButton - size="s" - style={{ float: 'right' }} - onClick={() => { - validateFieldValues(); - setIsPopoverValueOpen(updateArrayElement(isPopoverValueOpen, index, false)); - }} - > - Validate - </EuiButton> - </EuiPopoverFooter> - ); - } - }; - - const addFieldValue = (i, selectedOption) => { - setSearchFields( - updateSearchFieldValues( - searchFields, - index, - updateArrayElement(searchFields[index].values, i, { option: selectedOption }) - ) - ); - }; - - const getListFieldValues = () => { - const listFieldValues = []; - standardFields - .find((item) => item.field_name === searchFields[index].name) - .values.split(', ') - .sort() - .forEach((element) => { - listFieldValues.push({ label: element }); - }); - return listFieldValues; - }; - - switch (searchFields[index].type) { - case 'Text': - return ( - <> - <EuiFlexItem> - <EuiFieldText - placeholder={'Type values'} - value={searchFields[index].values} - onChange={(e) => - setSearchFields( - updateSearchFieldValues(searchFields, index, e.target.value) - ) - } - /> - </EuiFlexItem> - <EuiPopoverFooter> - <EuiButton - size="s" - style={{ float: 'right' }} - onClick={() => { - validateFieldValues(); - setIsPopoverValueOpen( - updateArrayElement(isPopoverValueOpen, index, false) - ); - }} - > - Validate - </EuiButton> - </EuiPopoverFooter> - </> - ); - case 'List': - return ( - <> - <EuiFormRow error={valueError} isInvalid={valueError !== undefined}> - <EuiComboBox - placeholder={'Select values'} - options={getListFieldValues()} - selectedOptions={searchFields[index].values} - onChange={(selectedOptions) => { - setValueError(undefined); - setSearchFields( - updateSearchFieldValues(searchFields, index, selectedOptions) - ); - }} - onSearchChange={onValueSearchChange} - /> - </EuiFormRow> - <EuiPopoverFooter> - <EuiButton - size="s" - style={{ float: 'right' }} - onClick={() => { - validateFieldValues(); - setIsPopoverValueOpen( - updateArrayElement(isPopoverValueOpen, index, false) - ); - }} - > - Validate - </EuiButton> - </EuiPopoverFooter> - </> - ); - - case 'Numeric': - const NumericValues = (i) => { - if (!!searchFields[index].values[i].option) { - switch (searchFields[index].values[i].option) { - case 'between': - return ( - <> - <EuiFlexItem> - <EuiFieldText - placeholder={'1st value'} - value={searchFields[index].values[i].value1} - onChange={(e) => { - setSearchFields( - updateSearchFieldValues( - searchFields, - index, - updateArrayElement(searchFields[index].values, i, { - option: searchFields[index].values[i].option, - value1: e.target.value, - value2: searchFields[index].values[i].value2, - }) - ) - ); - }} - /> - </EuiFlexItem> - <EuiFlexItem> - <EuiFieldText - placeholder={'2nd value'} - value={searchFields[index].values[i].value2} - onChange={(e) => - setSearchFields( - updateSearchFieldValues( - searchFields, - index, - updateArrayElement(searchFields[index].values, i, { - option: searchFields[index].values[i].option, - value1: searchFields[index].values[i].value1, - value2: e.target.value, - }) - ) - ) - } - /> - </EuiFlexItem> - {ValuePopoverFooter(i)} - </> - ); - - default: - return ( - <> - <EuiFlexItem> - <EuiFieldText - placeholder={'Type value'} - value={searchFields[index].values[i].value1} - onChange={(e) => { - setSearchFields( - updateSearchFieldValues( - searchFields, - index, - updateArrayElement(searchFields[index].values, i, { - option: searchFields[index].values[i].option, - value1: e.target.value, - value2: searchFields[index].values[i].value2, - }) - ) - ); - }} - /> - </EuiFlexItem> - {ValuePopoverFooter(i)} - </> - ); - } - } - }; - - return ( - <> - {searchFields[index].values.map((value, i) => ( - <div key={i}> - <EuiSelect - hasNoInitialSelection - id="Select an option" - options={NumericOptions} - value={searchFields[index].values[i].option} - onChange={(e) => { - addFieldValue(i, e.target.value); - invalidateFieldValues(); - }} - /> - {NumericValues(i)} - </div> - ))} - </> - ); - - case 'Date': - const SelectDates = (i) => { - if (!!searchFields[index].values[i].option) { - switch (searchFields[index].values[i].option) { - case 'between': - return ( - <> - <form className={datePickerStyles.container} noValidate> - <TextField - label="between" - type="date" - defaultValue={ - !!searchFields[index].values[i].startDate - ? searchFields[index].values[i].startDate - : Date.now() - } - className={datePickerStyles.textField} - InputLabelProps={{ - shrink: true, - }} - onChange={(e) => - setSearchFields( - updateSearchFieldValues( - searchFields, - index, - updateArrayElement(searchFields[index].values, i, { - option: searchFields[index].values[i].option, - startDate: e.target.value, - endDate: searchFields[index].values[i].endDate, - }) - ) - ) - } - /> - </form> - <form className={datePickerStyles.container} noValidate> - <TextField - label="and" - type="date" - defaultValue={ - !!searchFields[index].values[i].endDate - ? searchFields[index].values[i].endDate - : Date.now() - } - className={datePickerStyles.textField} - InputLabelProps={{ - shrink: true, - }} - onChange={(e) => - setSearchFields( - updateSearchFieldValues( - searchFields, - index, - updateArrayElement(searchFields[index].values, i, { - option: searchFields[index].values[i].option, - startDate: searchFields[index].values[i].startDate, - endDate: e.target.value, - }) - ) - ) - } - /> - </form> - {ValuePopoverFooter(i)} - </> - ); - - default: - return ( - <> - <form className={datePickerStyles.container} noValidate> - <TextField - type="date" - defaultValue={ - !!searchFields[index].values[i].startDate - ? searchFields[index].values[i].startDate - : Date.now() - } - className={datePickerStyles.textField} - InputLabelProps={{ - shrink: true, - }} - onChange={(e) => - setSearchFields( - updateSearchFieldValues( - searchFields, - index, - updateArrayElement(searchFields[index].values, i, { - option: searchFields[index].values[i].option, - startDate: e.target.value, - endDate: Date.now(), - }) - ) - ) - } - /> - </form> - {ValuePopoverFooter(i)} - </> - ); - } - } - }; - - return ( - <> - {searchFields[index].values.map((value, i) => ( - <div key={i}> - <EuiSelect - hasNoInitialSelection - id="Select an option" - options={DateOptions} - value={searchFields[index].values[i].option} - onChange={(e) => { - addFieldValue(i, e.target.value); - invalidateFieldValues(); - }} - /> - {SelectDates(i)} - </div> - ))} - </> - ); - default: - } -}; - -const PopoverValueButton = ( - index, - standardFields, - setStandardFields, - searchFields, - setSearchFields, - isPopoverValueOpen, - setIsPopoverValueOpen, - valueError, - setValueError, - search, - setSearch, - setSearchCount, - fieldCount, - setFieldCount, - selectedOperatorId, - datePickerStyles, - createPolicyToast, - selectedSources, - setSelectedSources, - availableSources, - setAvailableSources -) => { - return ( - <EuiPopover - panelPaddingSize="s" - button={ - <EuiButtonIcon - size="s" - color="primary" - onClick={() => - setIsPopoverValueOpen( - updateArrayElement(isPopoverValueOpen, index, !isPopoverValueOpen[index]) - ) - } - iconType="documentEdit" - title="Give field values" - /> - } - isOpen={isPopoverValueOpen[index]} - closePopover={() => - setIsPopoverValueOpen(updateArrayElement(isPopoverValueOpen, index, false)) - } - > - {/*<div style={{ width: 240 }}> - <EuiButtonIcon - size="s" - style={{ float: 'right' }} - color="danger" - onClick={() => setIsPopoverValueOpen(updateArrayElement(isPopoverValueOpen, index, false))} - iconType="crossInACircleFilled" - title="Close popover" - /> - </div>*/} - <div style={{ width: 240 }}> - {PopoverValueContent( - index, - standardFields, - setStandardFields, - searchFields, - setSearchFields, - valueError, - setValueError, - search, - setSearch, - setSearchCount, - fieldCount, - setFieldCount, - isPopoverValueOpen, - setIsPopoverValueOpen, - selectedOperatorId, - datePickerStyles, - createPolicyToast, - selectedSources, - setSelectedSources, - availableSources, - setAvailableSources - )} - </div> - </EuiPopover> - ); -}; - -const FieldsPanel = ( - standardFields, - setStandardFields, - searchFields, - setSearchFields, - selectedField, - setSelectedField, - selectedSection, - setSelectedSection, - isPopoverSelectOpen, - setIsPopoverSelectOpen, - isPopoverValueOpen, - setIsPopoverValueOpen, - valueError, - setValueError, - search, - setSearch, - setSearchCount, - selectedOperatorId, - setSelectedOperatorId, - fieldCount, - setFieldCount, - availableSources, - setAvailableSources, - selectedSources, - setSelectedSources, - sources, - datePickerStyles, - createPolicyToast -) => { - const countFieldValues = (field, index) => { - const fieldStr = `{${fieldValuesToString(field)}}`; - const queriesWithIndices = createAdvancedQueriesBySource( - standardFields, - fieldStr, - selectedSources, - availableSources - ); - getQueryCount(queriesWithIndices).then((result) => { - if (result || result === 0) - setFieldCount(updateArrayElement(fieldCount, index, result)); - }); - }; - - const handleRemoveField = (index) => { - const updatedSearchFields = removeArrayElement(searchFields, index); - setSearchFields(updatedSearchFields); - updateSources(updatedSearchFields, sources, setSelectedSources, setAvailableSources); - updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); - }; - - const handleClearValues = (index) => { - let updatedSearchFields = []; - switch (searchFields[index].type) { - case 'Text': - updatedSearchFields = updateArrayElement( - searchFields, - index, - new SearchField( - searchFields[index].name, - searchFields[index].type, - '', - false, - searchFields[index].sources - ) - ); - break; - case 'List': - updatedSearchFields = updateArrayElement( - searchFields, - index, - new SearchField( - searchFields[index].name, - searchFields[index].type, - [], - false, - searchFields[index].sources - ) - ); - break; - default: - updatedSearchFields = updateArrayElement( - searchFields, - index, - new SearchField( - searchFields[index].name, - searchFields[index].type, - [{}], - false, - searchFields[index].sources - ) - ); - } - setSearchFields(updatedSearchFields); - updateSources(updatedSearchFields, sources, setSelectedSources, setAvailableSources); - setFieldCount(updateArrayElement(fieldCount, index)); - updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); - }; - - if (standardFields === []) { - return <h2>Loading user fields...</h2>; - } - - return ( - <> - <EuiTitle size="xs"> - <h2>Field search</h2> - </EuiTitle> - <EuiPanel paddingSize="m"> - <EuiFlexGroup direction="column"> - {searchFields.map((field, index) => ( - <EuiPanel key={'field' + index} paddingSize="s"> - <EuiFlexItem grow={false}> - <EuiFlexGroup direction="row" alignItems="center"> - <EuiFlexItem grow={false}> - <EuiButtonIcon - size="s" - color="danger" - onClick={() => handleRemoveField(index)} - iconType="indexClose" - title="Remove field" - /> - </EuiFlexItem> - <EuiFlexItem> - {field.isValidated ? ( - <> - {field.sources.length ? ( - <EuiHealth color="danger"> - {fieldValuesToString(field).replace(/_|\./g, ' ')} - </EuiHealth> - ) : ( - <EuiHealth color="primary"> - {fieldValuesToString(field).replace(/_|\./g, ' ')} - </EuiHealth> - )} - </> - ) : ( - <> - {field.sources.length ? ( - <EuiHealth color="danger"> - {field.name.replace(/_|\./g, ' ')} - </EuiHealth> - ) : ( - <EuiHealth color="primary"> - {field.name.replace(/_|\./g, ' ')} - </EuiHealth> - )} - </> - )} - </EuiFlexItem> - <EuiFlexItem grow={false}> - {isNaN(fieldCount[index]) ? ( - <></> - ) : ( - <> - <EuiTextColor color="secondary"> - {fieldCount[index]}{' '} - {fieldCount[index] === 1 ? 'result' : 'results'} - </EuiTextColor> - </> - )} - </EuiFlexItem> - <EuiFlexItem grow={false}> - {field.isValidated ? ( - <> - <EuiButtonIcon - size="s" - onClick={() => countFieldValues(field, index)} - iconType="number" - title="Count results" - /> - </> - ) : ( - <></> - )} - </EuiFlexItem> - <EuiFlexItem grow={false}> - {field.isValidated ? ( - <> - <EuiButtonIcon - size="s" - color="danger" - onClick={() => handleClearValues(index)} - iconType="trash" - title="Clear values" - /> - </> - ) : ( - <></> - )} - </EuiFlexItem> - <EuiFlexItem grow={false}> - {PopoverValueButton( - index, - standardFields, - setStandardFields, - searchFields, - setSearchFields, - isPopoverValueOpen, - setIsPopoverValueOpen, - valueError, - setValueError, - search, - setSearch, - setSearchCount, - fieldCount, - setFieldCount, - selectedOperatorId, - datePickerStyles, - createPolicyToast, - selectedSources, - setSelectedSources, - availableSources, - setAvailableSources - )} - </EuiFlexItem> - </EuiFlexGroup> - </EuiFlexItem> - </EuiPanel> - ))} - </EuiFlexGroup> - <EuiSpacer size="l" /> - {PopoverSelect( - standardFields, - setStandardFields, - searchFields, - setSearchFields, - selectedField, - setSelectedField, - selectedSection, - setSelectedSection, - isPopoverSelectOpen, - setIsPopoverSelectOpen, - fieldCount, - setFieldCount, - selectedSources, - setSelectedSources - )} - </EuiPanel> - <EuiSpacer size="s" /> - <EuiRadioGroup - options={Operators} - idSelected={selectedOperatorId} - onChange={(id) => { - setSelectedOperatorId(id); - updateSearch(setSearch, searchFields, id, setSearchCount); - }} - name="operators group" - legend={{ - children: <span>Search option</span>, - }} - /> - </> - ); -}; - -const SourceSelect = ( - availableSources, - selectedSources, - setSelectedSources, - sourceSelectError, - setSourceSelectError -) => { - if (Object.keys(availableSources).length !== 0) { - availableSources.forEach((source) => { - if (source.name) { - source = changeNameToLabel(source); - } - }); - - const onSourceChange = (selectedOptions) => { - setSourceSelectError(undefined); - setSelectedSources(selectedOptions); - }; - - const onSourceSearchChange = (value, hasMatchingOptions) => { - setSourceSelectError( - value.length === 0 || hasMatchingOptions - ? undefined - : `"${value}" is not a valid option` - ); - }; - return ( - <> - <EuiTitle size="xs"> - <h2>Partner sources</h2> - </EuiTitle> - <EuiSpacer size="s" /> - <EuiFlexItem> - <EuiFormRow - error={sourceSelectError} - isInvalid={sourceSelectError !== undefined} - > - <EuiComboBox - placeholder="By default, all sources are selected" - options={availableSources} - selectedOptions={selectedSources} - onChange={onSourceChange} - onSearchChange={onSourceSearchChange} - /> - </EuiFormRow> - </EuiFlexItem> - </> - ); - } else { - return ( - <p> - <EuiIcon type="alert" color="danger" /> No source available ! - </p> - ); - } -}; +import { useTranslation } from 'react-i18next'; +import AdvancedSearch from './AdvancedSearch/AdvancedSearch'; +import BasicSearch from './BasicSearch/BasicSearch'; const Search = () => { - const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation('search'); const [selectedTabNumber, setSelectedTabNumber] = useState(0); - const [userHistory, setUserHistory] = useState({}); - const [advancedSearch, setAdvancedSearch] = useState(false); - const [readOnlyQuery, setReadOnlyQuery] = useState(true); - const [selectedField, setSelectedField] = useState([]); - const [selectedSection, setSelectedSection] = useState([]); - const [isPopoverSelectOpen, setIsPopoverSelectOpen] = useState(false); - const [isPopoverValueOpen, setIsPopoverValueOpen] = useState([false]); + const [isAdvancedSearch, setIsAdvancedSearch] = useState(false); const [selectedSources, setSelectedSources] = useState([]); + const [sources, setSources] = useState([]); const [availableSources, setAvailableSources] = useState([]); - const [sourceSelectError, setSourceSelectError] = useState(undefined); - const [valueError, setValueError] = useState(undefined); const [search, setSearch] = useState(''); - const [searchName, setSearchName] = useState(''); - const [searchDescription, setSearchDescription] = useState(''); const [basicSearch, setBasicSearch] = useState(''); - const [selectedOperatorId, setSelectedOperatorId] = useState('0'); - const [searchFields, setSearchFields] = useState([]); const [standardFields, setStandardFields] = useState([]); - const [sources, setSources] = useState([]); - const [searchResults, setSearchResults] = useState(); - const [searchCount, setSearchCount] = useState(); - const [fieldCount, setFieldCount] = useState([]); - const [isReadOnlyModalOpen, setIsReadOnlyModalOpen] = useState(false); - const [isSaveSearchModalOpen, setIsSaveSearchModalOpen] = useState(false); - const [selectedSavedSearch, setSelectedSavedSearch] = useState(); - const [historySelectError, setHistorySelectError] = useState(undefined); - const [notificationToasts, setNotificationToasts] = useState([]); - const datePickerStyles = useStyles(); + const [searchResults, setSearchResults] = useState([]); + const [selectedResultsRowsIds, setSelectedResultsRowsIds] = useState([]); useEffect(() => { fetchPublicFields().then((resultStdFields) => { @@ -1576,264 +60,83 @@ const Search = () => { setStandardFields(removeNullFields(userFields)); } ); - - // policyField => { - // policyField.forEach( }); fetchSources(sessionStorage.getItem('user_id')).then((result) => { setSources(result); setAvailableSources(result); }); - fetchHistory(setUserHistory); }, []); - const createPolicyToast = () => { - const toast = { - title: 'Policy field selected', - color: 'warning', - iconType: 'alert', - toastLifeTimeMs: 15000, - text: ( - <> - <p>You selected a private field.</p> - <p> - Access to this field was granted for specific sources, which means that your - search will be restricted to those. - </p> - <p>Please check the sources list before searching.</p> - </> - ), - }; - setNotificationToasts(notificationToasts.concat(toast)); - }; - - const createEditableQueryToast = () => { - const toast = { - title: 'Proceed with caution', - color: 'warning', - iconType: 'alert', - toastLifeTimeMs: 15000, - text: ( - <> - <p> - Be aware that manually editing the query can spoil search results. The syntax - must be respected : - </p> - <ul> - Fields and their values should be put between brackets : { } - Make - sure every opened bracket is properly closed - </ul> - <ul> - "AND" and "OR" should be capitalized between different fields conditions and - lowercased within a field expression - </ul> - <ul>Make sure to check eventual typing mistakes</ul> - </> - ), - }; - setNotificationToasts(notificationToasts.concat(toast)); - }; - - const removeToast = (removedToast) => { - setNotificationToasts( - notificationToasts.filter((toast) => toast.id !== removedToast.id) - ); - }; - const tabsContent = [ { id: 'tab1', - name: 'Compose search', + name: t('tabs.composeSearch'), content: ( - <> - {advancedSearch ? ( - <> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiSpacer size="s" /> - <EuiButtonEmpty - onClick={() => { - setAdvancedSearch(!advancedSearch); - }} - > - Switch to basic search - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - <EuiFlexGroup> - <EuiFlexItem> - <EuiSpacer size="s" /> - {SearchBar( - isLoading, - setIsLoading, - search, - setSearch, - searchResults, - setSearchResults, - searchFields, - setSearchFields, - searchName, - setSearchName, - searchDescription, - setSearchDescription, - readOnlyQuery, - setReadOnlyQuery, - selectedSources, - setSelectedSources, - availableSources, - setAvailableSources, - standardFields, - sources, - setSelectedTabNumber, - searchCount, - setSearchCount, - setFieldCount, - isReadOnlyModalOpen, - setIsReadOnlyModalOpen, - isSaveSearchModalOpen, - setIsSaveSearchModalOpen, - userHistory, - setUserHistory, - selectedSavedSearch, - setSelectedSavedSearch, - historySelectError, - setHistorySelectError, - selectedOperatorId, - createEditableQueryToast - )} - </EuiFlexItem> - </EuiFlexGroup> - <EuiFlexGroup> - <EuiFlexItem> - <EuiSpacer size="s" /> - {FieldsPanel( - standardFields, - setStandardFields, - searchFields, - setSearchFields, - selectedField, - setSelectedField, - selectedSection, - setSelectedSection, - isPopoverSelectOpen, - setIsPopoverSelectOpen, - isPopoverValueOpen, - setIsPopoverValueOpen, - valueError, - setValueError, - search, - setSearch, - setSearchCount, - selectedOperatorId, - setSelectedOperatorId, - fieldCount, - setFieldCount, - availableSources, - setAvailableSources, - selectedSources, - setSelectedSources, - sources, - datePickerStyles, - createPolicyToast - )} - <EuiSpacer size="s" /> - {SourceSelect( - availableSources, - selectedSources, - setSelectedSources, - sourceSelectError, - setSourceSelectError - )} - </EuiFlexItem> - </EuiFlexGroup> - <EuiGlobalToastList - toasts={notificationToasts} - dismissToast={removeToast} - toastLifeTimeMs={2500} + <EuiFlexGroup> + <EuiFlexItem> + <EuiSpacer size={'l'} /> + {isAdvancedSearch ? ( + <AdvancedSearch + search={search} + setSearch={setSearch} + setSearchResults={setSearchResults} + selectedSources={selectedSources} + setSelectedSources={setSelectedSources} + availableSources={availableSources} + setAvailableSources={setAvailableSources} + standardFields={standardFields} + setStandardFields={setStandardFields} + sources={sources} + setSelectedTabNumber={setSelectedTabNumber} + isAdvancedSearch={isAdvancedSearch} + setIsAdvancedSearch={setIsAdvancedSearch} + /> + ) : ( + <BasicSearch + isAdvancedSearch={isAdvancedSearch} + setIsAdvancedSearch={setIsAdvancedSearch} + standardFields={standardFields} + availableSources={availableSources} + selectedSources={selectedSources} + basicSearch={basicSearch} + setBasicSearch={setBasicSearch} + setSearchResults={setSearchResults} + setSelectedTabNumber={setSelectedTabNumber} /> - </> - ) : ( - <> - <EuiFlexGroup> - <EuiFlexItem grow={false}> - <EuiSpacer size="s" /> - <EuiButtonEmpty - onClick={() => { - setAdvancedSearch(!advancedSearch); - }} - > - Switch to advanced search - </EuiButtonEmpty> - </EuiFlexItem> - </EuiFlexGroup> - <EuiFlexGroup> - <EuiFlexItem> - <EuiSpacer size="s" /> - <EuiFlexGroup> - <EuiFlexItem> - <EuiFieldSearch - value={basicSearch} - onChange={(e) => setBasicSearch(e.target.value)} - placeholder="Search..." - fullWidth - /> - </EuiFlexItem> - <EuiFlexItem grow={false}> - <EuiButton - fill - isDisabled={advancedSearch} - onClick={() => { - setIsLoading(true); - const queriesWithIndices = createBasicQueriesBySource( - standardFields, - basicSearch, - selectedSources, - availableSources - ); - searchQuery(queriesWithIndices).then((result) => { - // sessionStorage.setItem("searchResults", JSON.stringify(result)) - setSearchResults(result); - setSelectedTabNumber(1); - setIsLoading(false); - }); - }} - > - Search - </EuiButton> - </EuiFlexItem> - </EuiFlexGroup> - {isLoading && ( - <EuiFlexGroup> - <EuiFlexItem> - <EuiProgress postion="fixed" size="l" color="accent" /> - </EuiFlexItem> - </EuiFlexGroup> - )} - </EuiFlexItem> - </EuiFlexGroup> - </> - )} - </> + )} + </EuiFlexItem> + </EuiFlexGroup> ), }, { - id: 'tab3', - name: 'Results', + id: 'tab2', + name: t('tabs.results'), content: ( <EuiFlexGroup> - <EuiFlexItem>{Results(searchResults, search, basicSearch)}</EuiFlexItem> + <EuiFlexItem> + <EuiSpacer size="l" /> + <Results + searchResults={searchResults} + searchQuery={isAdvancedSearch ? search : basicSearch} + selectedRowsIds={selectedResultsRowsIds} + setSelectedRowsIds={setSelectedResultsRowsIds} + /> + </EuiFlexItem> </EuiFlexGroup> ), }, { - id: 'tab2', - name: 'Map', + id: 'tab3', + name: t('tabs.map'), content: ( <EuiFlexGroup> <EuiFlexItem> <EuiSpacer size="l" /> - {/*<a href="https://agroenvgeo.data.inra.fr/mapfishapp/"><img src={map} width="460" height="400" alt='Map' /></a>*/} - <SearchMap searchResults={searchResults} /> + <SearchMap + searchResults={searchResults} + selectedPointsIds={selectedResultsRowsIds} + setSelectedPointsIds={setSelectedResultsRowsIds} + /> </EuiFlexItem> </EuiFlexGroup> ), @@ -1841,30 +144,15 @@ const Search = () => { ]; return ( - <> - <EuiPageContent> - {' '} - {/*style={{ backgroundColor: "#fafafa" }}*/} - <EuiPageContentHeader> - <EuiPageContentHeaderSection> - <EuiTitle> - <h2>In-Sylva Metadata Search Platform</h2> - </EuiTitle> - </EuiPageContentHeaderSection> - </EuiPageContentHeader> - <EuiPageContentBody> - <EuiForm> - <EuiTabbedContent - tabs={tabsContent} - selectedTab={tabsContent[selectedTabNumber]} - onTabClick={(tab) => { - setSelectedTabNumber(tabsContent.indexOf(tab)); - }} - /> - </EuiForm> - </EuiPageContentBody> - </EuiPageContent> - </> + <EuiPageContentBody> + <EuiTabbedContent + tabs={tabsContent} + selectedTab={tabsContent[selectedTabNumber]} + onTabClick={(tab) => { + setSelectedTabNumber(tabsContent.indexOf(tab)); + }} + /> + </EuiPageContentBody> ); };