diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..7dc10ba260fe116876c0ea0da0aaf85ab3b37cfd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +# Ignore node_modules directory +node_modules/ + +# Ignore npm debug log +npm-debug.log + +# Ignore build artifacts +dist/ +build/ +out/ + +# Ignore development files +*.log +*.pid +*.lock + +# Ignore version control files +.git/ +.gitignore + +# Ignore certificates +ssl/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..f0674db04d9dbb5b846a23974a0e0794f7172534 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,16 @@ +stages: + - build + +build: + stage: build + image: docker:latest + services: + - docker:dind + script: + - apk add jq + - version=$(jq -r .version package.json) + - cat $ENV_PRODUCTION > .env.production + - docker build -t registry.forgemia.inra.fr/in-sylva-development/in-sylva.search.app:$version . + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - docker push registry.forgemia.inra.fr/in-sylva-development/in-sylva.search.app:$version + when: manual diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d2c2bcb6bc408df47f9ec98baed359e1b284ba5d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:18.20.5 as builder + +WORKDIR /app/ +COPY package.json . +RUN yarn install +COPY . . +RUN yarn build + +FROM nginx:1.24-bullseye +COPY --from=builder /app/build /usr/share/nginx/html + +RUN rm /etc/nginx/conf.d/default.conf +COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf +COPY nginx/gzip.conf /etc/nginx/conf.d/gzip.conf + +WORKDIR /usr/share/nginx/html +RUN chown -R :www-data /usr/share/nginx/html + +COPY ./env.sh . +RUN chmod +x env.sh + +COPY .env.production .env +RUN ./env.sh -e .env -o ./ + +CMD ["nginx", "-g", "daemon off;"] diff --git a/env.sh b/env.sh new file mode 100755 index 0000000000000000000000000000000000000000..5c94083cf6ba0fb86ef903310d3b9034474cdb83 --- /dev/null +++ b/env.sh @@ -0,0 +1,121 @@ +#!/bin/bash + +usage() { + BASENAME=$(basename "$0") + echo "Usage: $BASENAME -e <env-file> -o <output-file>" + echo " -e, --env-file Path to .env file" + echo " -o, --output-file Path to output '.env-config.js' file" + exit 1 +} + +while getopts "e:o:" opt; do + case $opt in + e) + ENV_FILE=$OPTARG + ;; + o) + OUT_DIRECTORY=$OPTARG + ;; + \?) + # Invalid option + echo "Invalid option: -$OPTARG" + usage + exit 1 + ;; + :) + echo "Option -$OPTARG requires an argument." + usage + exit 1 + ;; + esac +done + +# Check if required options are provided +if [ -z "$OUT_DIRECTORY" ]; then + echo "Error: -o (output directory) is required." + echo "" + usage + exit 1 +fi + +if [ -z "$ENV_FILE" ]; then + echo "Error: -e (environment file) is required." + echo "" + usage + exit 1 +fi + +ENV_FILE_PATH=$(realpath $ENV_FILE) + +# Check if the environment file exists +if [[ ! -f $ENV_FILE_PATH ]]; then + echo "Environment file does not exist" + echo "" + usage + exit 1 +fi + +# Check if the environment file is readable +if [[ ! -r $ENV_FILE_PATH ]]; then + echo "Environment file is not readable" + echo "" + usage + exit 1 +fi + +# Check if the environment file is empty +if [[ ! -s $ENV_FILE_PATH ]]; then + echo "Environment file is empty" + echo "" + usage + exit 1 +fi + +# Check if the environment file has the correct format +BAD_LINES=$(grep -v -P '^[^=\s]+=[^=\s]+$' "$ENV_FILE_PATH") +if [ -n "$BAD_LINES" ]; then + echo "Environment file has incorrect format or contains empty values:" + echo "$BAD_LINES" + echo "" + usage + exit 1 +fi + +FULL_PATH_OUTPUT_DIRECTORY=$(realpath $OUT_DIRECTORY) + +# Check if the output directory is a directory, exists, and can be written +if [[ ! -d $FULL_PATH_OUTPUT_DIRECTORY ]]; then + echo "Output directory does not exist or is not a directory" + echo "" + usage + exit 1 +fi + +if [[ ! -w $FULL_PATH_OUTPUT_DIRECTORY ]]; then + echo "Output directory is not writable" + echo "" + usage + exit 1 +fi + +OUTPUT_FILE="env-config.js" +FULL_OUTPUT_PATH="$FULL_PATH_OUTPUT_DIRECTORY/$OUTPUT_FILE" + +# Remove the output file if it exists +if [[ -f $FULL_OUTPUT_PATH ]]; then + rm $FULL_OUTPUT_PATH +fi + +# Add assignment +echo "window._env_ = {" >>$FULL_OUTPUT_PATH + +while read -r line || [[ -n "$line" ]]; do + # Split env variables by '=' + varname=$(echo $line | cut -d'=' -f1) + value=$(echo $line | cut -d'=' -f2) + + # Append configuration property to JS file + echo " $varname: \"$value\"," >>$FULL_OUTPUT_PATH +done <$ENV_FILE_PATH + +echo "}" >>$FULL_OUTPUT_PATH diff --git a/nginx/gzip.conf b/nginx/gzip.conf new file mode 100644 index 0000000000000000000000000000000000000000..2f54ae1e8e846ed57825b4597e24077efa3a6277 --- /dev/null +++ b/nginx/gzip.conf @@ -0,0 +1,44 @@ +# Enable Gzip compressed. +# gzip on; + + # Enable compression both for HTTP/1.0 and HTTP/1.1 (required for CloudFront). + gzip_http_version 1.0; + + # Compression level (1-9). + # 5 is a perfect compromise between size and cpu usage, offering about + # 75% reduction for most ascii files (almost identical to level 9). + gzip_comp_level 5; + + # Don't compress anything that's already small and unlikely to shrink much + # if at all (the default is 20 bytes, which is bad as that usually leads to + # larger files after gzipping). + gzip_min_length 256; + + # Compress data even for clients that are connecting to us via proxies, + # identified by the "Via" header (required for CloudFront). + gzip_proxied any; + + # Tell proxies to cache both the gzipped and regular version of a resource + # whenever the client's Accept-Encoding capabilities header varies; + # Avoids the issue where a non-gzip capable client (which is extremely rare + # today) would display gibberish if their proxy gave them the gzipped version. + gzip_vary on; + + # Compress all output labeled with one of the following MIME-types. + gzip_types + application/atom+xml + application/javascript + application/json + application/rss+xml + application/vnd.ms-fontobject + application/x-font-ttf + application/x-web-app-manifest+json + application/xhtml+xml + application/xml + font/opentype + image/svg+xml + image/x-icon + text/css + text/plain + text/x-component; + # text/html is always compressed by HttpGzipModule \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..3ed620d20b90e99365f4078061fcf0619613e11d --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,57 @@ +include /etc/nginx/mime.types; + +server { + + listen 80; + server_name -; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header X-Forwarded-Proto $scheme; + + access_log /var/log/nginx/host.access.log; + error_log /var/log/nginx/host.error.log; + + root /usr/share/nginx/html; + index index.html index.htm; + + location /static/media/ { + try_files $uri /usr/share/nginx/html/static/media; + } + + location / { + + root /usr/share/nginx/html; + index index.html; + autoindex on; + set $fallback_file /index.html; + if ($http_accept !~ text/html) { + set $fallback_file /null; + } + if ($uri ~ /$) { + set $fallback_file /null; + } + try_files $uri $fallback_file; + + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + add_header 'Access-Control-Allow-Origin' "$http_origin" always; + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With' always; + } + + error_page 500 502 503 504 /50x.html; + + location = /50x.html { + root /usr/share/nginx/html; + } +} diff --git a/package.json b/package.json index 893d7db724b5d118f6840ce440f7aaec4b1a9dec..9d5f3531bc2ee72ad1a787e181faca1da02b68c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "in-sylva.search", - "version": "1.0.0", + "version": "1.1.4", "private": true, "homepage": ".", "dependencies": { @@ -17,6 +17,7 @@ "i18next-http-backend": "^2.5.2", "moment": "^2.27.0", "mui-datatables": "^4.3.0", + "oidc-react": "^3.4.1", "ol": "^9.2.4", "proj4": "^2.11.0", "react": "^18.3.1", @@ -29,8 +30,8 @@ "react-use-storage": "^0.5.1" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", + "start": "./env.sh -e .env.development -o ./public && BROWSER=none NODE_OPTIONS=--openssl-legacy-provider react-scripts start", + "build": "NODE_OPTIONS=--openssl-legacy-provider react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint ./src", @@ -44,8 +45,6 @@ ], "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.1.3", "husky": "8.0.3", "lint-staged": "14.0.1", "prettier": "^3.2.5" @@ -65,26 +64,25 @@ "es6": true }, "extends": [ - "plugin:react/recommended", - "plugin:prettier/recommended" + "eslint:recommended", + "plugin:react/recommended" + ], + "plugins": [ + "react", + "react-hooks" ], "parserOptions": { "ecmaFeatures": { "jsx": true } - }, - "plugins": [ - "react" - ] + } }, "lint-staged": { "*.+(js|jsx)": [ - "eslint --fix", - "git add" + "eslint --fix" ], "*.+(json|css|md)": [ - "prettier --write", - "git add" + "prettier --write" ] }, "prettier": { diff --git a/public/env-config.js b/public/env-config.js new file mode 100644 index 0000000000000000000000000000000000000000..c0447a73cd3f794b0ab75405d4946f3ae771648e --- /dev/null +++ b/public/env-config.js @@ -0,0 +1,9 @@ +window._env_ = { + REACT_APP_BASE_URL: "http://localhost:3000", + REACT_APP_KEYCLOAK_BASE_URL: "https://in-sylva.inrae.fr/keycloak/realms/in-sylva", + REACT_APP_KEYCLOAK_CLIENT_ID: "in-sylva.user.app", + REACT_APP_KEYCLOAK_CLIENT_SECRET: "O5igbRB7R2JLMKLv2meXDwBJ802Hd1MZ", + REACT_APP_IN_SYLVA_GATEKEEPER_BASE_URL: "http://localhost:3001", + REACT_APP_IN_SYLVA_PORTAL_BASE_URL: "http://localhost:3002", + NODE_TLS_REJECT_UNAUTHORIZED: "0", +} diff --git a/public/index.html b/public/index.html index 5e6c56481c2b2e5db90e8168d6f8261aab9e9f72..d605a927d3bf768e6de1dec9b7cd7182d6851041 100644 --- a/public/index.html +++ b/public/index.html @@ -1,12 +1,13 @@ <!DOCTYPE html> <html lang="en"> -<head> - <meta charset="utf-8" /> - <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" /> - <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> - <!-- + <head> + <meta charset="utf-8" /> + <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" /> + <meta name="viewport" + content="width=device-width, initial-scale=1, shrink-to-fit=no" /> + <meta name="theme-color" content="#000000" /> + <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. Only files inside the `public` folder can be referenced from the HTML. @@ -15,11 +16,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> - <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> -</body> + <title>IN-SYLVA Search</title> + <script src="%PUBLIC_URL%/env-config.js"></script> + </head> + <body style="font-family: 'Roboto', sans-serif;"> + <noscript>You need to enable JavaScript to run this app.</noscript> + <div id="root"></div> + </body> </html> diff --git a/public/locales/en/about.json b/public/locales/en/about.json new file mode 100644 index 0000000000000000000000000000000000000000..7bcb0eb262a9c510e1f113b1cea7460e7940b0f4 --- /dev/null +++ b/public/locales/en/about.json @@ -0,0 +1,12 @@ +{ + "pageTitle": "Welcome on In-Sylva search module.", + "abouts": [ + "Metadata handled in the In-Sylva Information System describe the infrastructure's resources.", + "The resources are, to begin with, experimental systems linked to sylviculture, genetics or biogeochemical cycles.", + "Metadata are structured around a standard established by the In-Sylva community (<0>https://entrepot.recherche.data.gouv.fr/file.xhtml?persistentId=doi:10.15454/ELXRGY/NCVTVR&version=5.1</0>)", + "The query portal allows to search metadata sheets of interests according to different criteria and subject to access rights.", + "Your rights are those of the group(s) to which you belong. To find out more, check your profile <1>here</1> or on the right of the menu banner.", + "The ‘Search’ interface opens a ‘Basic search’ text box. Selecting ‘Advanced search’ mode allows you to build a more elaborate search.", + "Enjoy your research!" + ] +} diff --git a/public/locales/en/header.json b/public/locales/en/header.json index 40943129698cb4bede32b102dc83bc507b6fa488..a9ee23dafc237e17f56e1435fb2c44799ecfe400 100644 --- a/public/locales/en/header.json +++ b/public/locales/en/header.json @@ -1,6 +1,11 @@ { + "homepageRedirect": "Go to homepage", + "portalLink": { + "title": "Go to Portal", + "tooltip": "Manage your sources with Portal" + }, "tabs": { - "home": "Home", + "about": "About", "search": "Search" }, "userMenu": { diff --git a/public/locales/en/home.json b/public/locales/en/home.json deleted file mode 100644 index 7442e938b671be31ae96ea4c7388eb58eaf4b3b7..0000000000000000000000000000000000000000 --- a/public/locales/en/home.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "pageTitle": "Welcome on In-Sylva search module's homepage.", - "searchToolDescription": { - "standardLink": "Follow this link to find a complete documentation of the current INSYLVA standard: ", - "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": "This tool will help you 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 index c71540d2927d968996dd968daa8b1dd9482bc1f9..fb3a125d51428f5139acbd6b1c4689a760f40984 100644 --- a/public/locales/en/maps.json +++ b/public/locales/en/maps.json @@ -1,29 +1,37 @@ { - "layersTableHeaders": { - "cartography": "Cartography", - "filters": "Filters", - "tools": "Tools" + "noResults": "You don't have any results. Search to display points on the map.", + "howTo": { + "zoomHelperText": "Use ctrl + scroll to zoom the map" }, - "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" + "mapTools": { + "layers": { + "title": "Cartography", + "openStreetMap": "Open Street Map", + "bingAerial": "Bing Aerial", + "IGN": "IGN map" + }, + "filters": { + "title": "Filters", + "queryResults": "Query results", + "regions": "Regions", + "departments": "Departments", + "sylvoEcoRegions": "SylvoEcoRegions", + "selectFilterOption": "Select a single option" + }, + "tools": { + "title": "Tools", + "selectionTool": { + "title": "Point selection mode", + "select": "Add", + "unselect": "Remove", + "unselectAll": "Unselect all points" + } } }, "selectedPointsList": { "title": "Selected resources", - "empty": "Select resources to display them here.", + "emptyTitle": "Select resources", + "empty": "Use CTRL + CLICK to select resources and display them here.", "actions": { "openResourceFlyout": "Open resource flyout", "unselectResource": "Unselect resource" diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 4daa91ec77982e69c7177aa1bdfb4b126b54af89..6a1b87d39868ca384bc6e0dfa6bc40e5fc3e69ab 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -33,7 +33,7 @@ }, "fieldsDisplaySettings": { "selectedOptionsLabel": "User display options for fields in results page", - "selectedOptionsNumber": "<strong>{{count}}</strong> fields selected:", + "selectedOptionsNumber": "<strong>{{count}}</strong> fields selected", "deleteSettings": "Delete settings", "noSettings": "You don't have any settings.", "resetSelection": "Reset selection", @@ -46,7 +46,8 @@ "groups": { "groupsList": "Existing groups", "groupName": "Name", - "groupDescription": "Description" + "groupDescription": "Description", + "groupMember": "Member ?" }, "requestsList": { "requestsList": "Requests list", diff --git a/public/locales/en/results.json b/public/locales/en/results.json index 66d1b63e5be83efd6ac21c1281a9684c998cd96b..7009a1a5ea62895f39c13f3a1119cf27cac1d0a8 100644 --- a/public/locales/en/results.json +++ b/public/locales/en/results.json @@ -1,4 +1,12 @@ { + "noResult": { + "title": "No results match your search criteria", + "body": { + "title": "Adjust your query", + "description": "Try searching for a different combination of terms." + }, + "action": "Back to search" + }, "clickOnRowTip": "Click on a row to display metadata.", "downloadResultsButtons": { "download": "Download", @@ -14,7 +22,7 @@ } }, "table": { - "title": "Search results from query: <strong>{{searchQuery}}</strong>", + "title": "<strong>{{count}}</strong> results for query: <strong>{{searchQuery}}</strong>", "search": "Search among results", "displaySelectedRowsButton": "Sort selection", "textLabels": { @@ -50,6 +58,7 @@ }, "flyout": { "label": "Resource data sheet", + "loading": "Loading data sheet.", "JSON": { "title": "Resource JSON data" } diff --git a/public/locales/en/search.json b/public/locales/en/search.json index 67d35bcd73a63090940f4fb383b52da082dd9fc8..fa770790948301588f2b7016cc85406b78534444 100644 --- a/public/locales/en/search.json +++ b/public/locales/en/search.json @@ -5,10 +5,75 @@ "map": "Map" }, "sendSearchButton": "Search", + "queryError": "Your query is not formed correctly.", "basicSearch": { "switchSearchMode": "Switch to advanced search", - "searchInputPlaceholder": "Search..." - }, + "searchInputPlaceholder": "Search...", + "howTo": { + "toggleAction": "How does basic search works ?", + "examplesTitle": "A few examples", + "examples": [ + { + "query": "Cedrus", + "desc": "searches for Cedrus in any field of the standard." + }, + { + "query": "biological_material.species:Cedrus", + "desc": "searches for Cedrus <strong>only</strong> in the species field of the standard." + }, + { + "query": "Cedr?s atlanti*", + "desc": "searches for Cedrus atlantica, Cedris atlanticus, and all other possibilities." + }, + { + "query": "\"Cedrus atlantica\" +(FCBA || INRAE)", + "desc": "searches \"Cedrus atlantica\" and <strong>necessarily</strong> FCBA or INRAE." + }, + { + "query": "\"Pinus nigra\" +experimental_site.geo_point.altitude:>1200 +experimental_site.start_date:[1970-01-01 TO 1999-01-01]", + "desc": "searches for black pine on an experimental site above 1200m high that started between 1970 and 1999." + }, + { + "query": "\"Abies alba\" -biological_material.genetic_level:clone", + "desc": "searches for Abies alba while excluding all clones." + } + ], + "sections": [ + { + "title": "Term based functionnality", + "content": [ + "Each term searches for an <strong>exact</strong> match among all standard fields.", + "Colon <strong>: </strong> allows to search in a <strong>specific standard field</strong>.", + "Quotes <strong>\" \"</strong> link terms <strong>in one and only</strong> character string." + ] + }, { + "title": "Wildcards", + "content": [ + "<strong>?</strong> matches any unique character.", + "<strong>*</strong> matches zero or any multiple character including an empty one." + ] + }, { + "title": "Priorities operators", + "content": [ + "Ampersand <strong>&&</strong> allows a <strong>logical and</strong> between two terms.", + "Double pipe <strong>||</strong> allows a <strong>logical or</strong> between two terms.", + "Parenthesis <strong>( )</strong> specify priority while using multiple operators.", + "<strong>+</strong> forces <strong>presence</strong> of the following terms.", + "<strong>-</strong> or <strong>!</strong> forces <strong>absence</strong> of the following terms." + ] + }, { + "title": "Comparison operators", + "content": [ + "<, >, <=, => allows numeric and date comparison. Dates in YYYY-MM-DD format.", + "Brackets <strong>[ ]</strong> to search in a inclusive interval.", + "Curly brackets <strong>{ }</strong> to search in a exclusive interval.", + "Intervals two <strong>endpoints</strong> must be linked with <strong>TO</strong>.", + "You can combine two types of brackets in a same interval: <strong>[ } or { ]</strong>." + ] + } + ] + } +}, "advancedSearch": { "switchSearchMode": "Switch to basic search", "textQueryPlaceholder": "Add fields...", diff --git a/public/locales/fr/about.json b/public/locales/fr/about.json new file mode 100644 index 0000000000000000000000000000000000000000..5634ad1cbbb841fb13c298ff1d4f60aabb1579ec --- /dev/null +++ b/public/locales/fr/about.json @@ -0,0 +1,12 @@ +{ + "pageTitle": "Bienvenue sur le module de recherche du Système d'Information In-Sylva", + "abouts": [ + "Les métadonnées gérées dans le Système d'Information (SI) In-Sylva décrivent les ressources de l'Infrastructure.", + "Les ressources sont dans un premier temps des dispositifs expérimentaux relatifs à la sylviculture, la génétique ou les cycles biogéochimique.", + "Les métadonnées sont structurées selon un standard établi par la communauté In-Sylva (<0>https://entrepot.recherche.data.gouv.fr/file.xhtml?persistentId=doi:10.15454/ELXRGY/NCVTVR&version=5.1</0>)", + "Le portail d'interrogation permet la recherche des fiches de métadonnées d'intérêt, selon différents critères et conditionnellement aux droits d'accès.", + "Vos droits sont ceux du (des) groupes auxquels vous appartenez. Pour en savoir plus, consultez votre profil <1>ici</1> ou à droite du bandeau de menu.", + "L'interface \"Recherche\" ouvre une zone de texte de \"Recherche basique\". La sélection en mode \"Recherche avancée\" permet de construire une requete plus élaborée.", + "Bonne recherche !" + ] +} diff --git a/public/locales/fr/header.json b/public/locales/fr/header.json index 23c3d26a36e7505c93101151aec1b4d0b34c1eeb..a1ab91c56b595787cc1072e12f256c01a7015fbe 100644 --- a/public/locales/fr/header.json +++ b/public/locales/fr/header.json @@ -1,6 +1,11 @@ { + "portalLink": { + "homepageRedirect": "Accéder à la page d'acceuil", + "title": "Accéder au Portal", + "tooltip": "Gérez vos sources avec le Portail" + }, "tabs": { - "home": "Page d'accueil", + "about": "A propos", "search": "Recherche" }, "userMenu": { diff --git a/public/locales/fr/home.json b/public/locales/fr/home.json deleted file mode 100644 index 61368490122745e23c183eaa0968da807178222f..0000000000000000000000000000000000000000 --- a/public/locales/fr/home.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "pageTitle": "Bienvenue sur la page d'accueil du module de recherche du Système d'Information In-Sylva", - "searchToolDescription": { - "standardLink": "Une documentation complète de ce standard est disponible à cette adresse : ", - "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 index 51f7f8d6099a93f9c3cc4733b165528a0031f195..765566210610a3055727f2161414f872ffc4085c 100644 --- a/public/locales/fr/maps.json +++ b/public/locales/fr/maps.json @@ -1,32 +1,40 @@ { - "layersTableHeaders": { - "cartography": "Cartographie", - "filters": "Filtres", - "tools": "Outils" + "noResults": "Aucun résultat. Effectuez une nouvelle recherche pour apercevoir des points sur la carte.", + "howTo": { + "zoomHelperText": "Utilisez ctrl + scroll pour zoomer" }, - "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" + "mapTools": { + "layers": { + "title": "Cartographie", + "openStreetMap": "Open Street Map", + "bingAerial": "Bing vue aérienne", + "IGN": "Plan IGN" + }, + "filters": { + "title": "Filtres", + "queryResults": "Résultats de la requête", + "regions": "Régions", + "departments": "Départements", + "sylvoEcoRegions": "SylvoEcoRégions", + "selectFilterOption": "Sélectionnez une option" + }, + "tools": { + "title": "Outils", + "selectionTool": { + "title": "Mode de sélection de points", + "select": "Ajout", + "unselect": "Suppression", + "unselectAll": "Vider la sélection" + } } }, "selectedPointsList": { "title": "Ressources sélectionnées", - "empty": "Sélectionnez des ressources pour les afficher ici", + "emptyTitle": "Aucune ressource sélectionnée", + "empty": "Utilisez CTRL + CLICK pour sélectionner des points de la carte.", "actions": { - "openResourceFlyout": "Ouvrir la fiche de la resource", - "unselectResource": "Désélectionner la resource" + "openResourceFlyout": "Ouvrir la fiche de la ressource", + "unselectResource": "Déselectionner la resource" } } } diff --git a/public/locales/fr/profile.json b/public/locales/fr/profile.json index 22d784c4eb8194135ab2eda90e6176b5f1e4cb0c..60412a9d24d08e49b7ed300e95e83445201118db 100644 --- a/public/locales/fr/profile.json +++ b/public/locales/fr/profile.json @@ -46,7 +46,8 @@ "groups": { "groupsList": "Groupes existants", "groupName": "Nom", - "groupDescription": "Description" + "groupDescription": "Description", + "groupMember": "Member ?" }, "requestsList": { "requestsList": "Liste des requêtes", diff --git a/public/locales/fr/results.json b/public/locales/fr/results.json index c8e2dd90522036090eab01276647ab2543bd3c7f..7bbe276b02fe9556f4ad355095ad2964d7eded06 100644 --- a/public/locales/fr/results.json +++ b/public/locales/fr/results.json @@ -1,4 +1,12 @@ { + "noResult": { + "title": "Aucun résultat", + "body": { + "title": "Ajustez votre recherche", + "description": "Essayez d'utiliser une combinaison de termes différents." + }, + "action": "Modifier ma requête" + }, "clickOnRowTip": "Clickez sur une ligne du tableau pour afficher ses métadonnées.", "downloadResultsButtons": { "download": "Télécharger", @@ -14,7 +22,7 @@ } }, "table": { - "title": "Résultats de la requête : <strong>{{searchQuery}}</strong>", + "title": "<strong>{{count}}</strong> résultats pour la requête : <strong>{{searchQuery}}</strong>", "search": "Chercher parmi les résultats", "displaySelectedRowsButton": "Trier la sélection", "textLabels": { @@ -50,6 +58,7 @@ }, "flyout": { "label": "Fiche de données de la ressource", + "loading": "Chargement des données", "JSON": { "title": "Données JSON de la ressource" } diff --git a/public/locales/fr/search.json b/public/locales/fr/search.json index 101410a07c2b32d4c86c6f9c2a6ae67164d311f8..9c9f41b09d0f5894ffac229e6c1fc1821cc6e580 100644 --- a/public/locales/fr/search.json +++ b/public/locales/fr/search.json @@ -5,9 +5,74 @@ "map": "Carte" }, "sendSearchButton": "Lancer la recherche", + "queryError": "Votre requête n'est pas formée correctement.", "basicSearch": { "switchSearchMode": "Passer en recherche avancée", - "searchInputPlaceholder": "Chercher..." + "searchInputPlaceholder": "Chercher...", + "howTo": { + "toggleAction": "Comment fonctionne la recherche basique ?", + "examplesTitle": "Quelques exemples", + "examples": [ + { + "query": "Cedrus", + "desc": "cherche Cedrus parmi l'ensemble des champs du standard." + }, + { + "query": "biological_material.species:Cedrus", + "desc": "cherche Cedrus <strong>uniquement</strong> dans le champ espèce du standard." + }, + { + "query": "Cedr?s atlanti*", + "desc": "cherche Cedrus atlantica, Cedris atlanticus, ainsi que toutes les autres possibilités." + }, + { + "query": "\"Cedrus atlantica\" +(FCBA || INRAE)", + "desc": "cherche \"Cedrus atlantica\" et <strong>obligatoirement</strong> un de FCBA ou INRAE." + }, + { + "query": "\"Pinus nigra\" +experimental_site.geo_point.altitude:>1200 +experimental_site.start_date:[1970-01-01 TO 1999-01-01]", + "desc": "cherche du pin noir sur un site exp. situé à plus de 1200m d'altitude, ayant débuté entre 1970 et 1999." + }, + { + "query": "\"Abies alba\" -biological_material.genetic_level:clone", + "desc": "cherche du Abies alba en excluant tous les clônes." + } + ], + "sections": [ + { + "title": "Fonctionnement par mots clés", + "content": [ + "Chaque terme cherche une correspondance <strong>exacte</strong> parmi tous les champs du standard.", + "Le <strong>deux-points : </strong>permet de chercher dans un <strong>champ spécifique du standard</strong>.", + "Les guillemets <strong>\" \"</strong> lient des termes en <strong>une seule et même</strong> chaîne de caractère." + ] + }, { + "title": "Caractères de remplacement", + "content": [ + "<strong>?</strong> qui correspond à n'importe quel caractère unique.", + "<strong>*</strong> qui peut correspondre à zéro ou plusieurs caractères, y compris un caractère vide." + ] + }, { + "title": "Opérateurs de priorités", + "content": [ + "Le et commercial <strong>&&</strong> permet un <strong>et logique</strong> entre deux termes.", + "La barre verticale <strong>||</strong> permet un <strong>ou logique</strong> entre deux termes.", + "Les parenthèses <strong>( )</strong> précisent la priorité dès que vous utilisez plusieurs opérateurs.", + "<strong>+</strong> oblige <strong>la présence</strong> des termes suivants.", + "<strong>-</strong> ou <strong>!</strong> obligent <strong>l'absence</strong> des termes suivants." + ] + }, { + "title": "Opérateurs de comparaison", + "content": [ + "<, >, <=, => permettent des comparaisons avec des valeurs numériques ou des dates (au format AAAA-MM-JJ).", + "Les crochets <strong>[ ]</strong> pour chercher dans un intervalle inclusif.", + "Les brackets <strong>{ }</strong> pour chercher dans un intervalle exclusif.", + "Les deux <strong>bornes</strong> d'un intervalle doivent être liés par le terme <strong>TO</strong>.", + "Vous pouvez combiner un crochet et un bracket dans le même intervalle: <strong>[ } ou { ]</strong>." + ] + } + ] + } }, "advancedSearch": { "switchSearchMode": "Passer en recherche basique", @@ -20,7 +85,7 @@ "fields": { "title": "Recherche de champ", "loadingFields": "Chargement des champs...", - "removeFieldButton": "Supprimer le champ", + "removeFieldButton": "Retirer le champ", "clearValues": "Vider les valeurs", "addFieldPopover": { "title": "Ajouter un champ", diff --git a/src/App.js b/src/App.js index 181dc8686a114cb0b47057ed5b87ea1b081d5ffe..af68f32bf4a166f5e05b57e1c09e27fa15df4a4b 100644 --- a/src/App.js +++ b/src/App.js @@ -1,26 +1,26 @@ import React from 'react'; import { - RouterProvider, createHashRouter, - Route, createRoutesFromElements, - Navigate, + Route, + RouterProvider, } from 'react-router-dom'; -import Home from './pages/home'; import Search from './pages/search'; import Profile from './pages/profile'; import Layout from './components/Layout'; import ErrorBoundary from './pages/error/ErrorBoundary'; +import About from './pages/about'; const App = () => { const router = createHashRouter( createRoutesFromElements( <> - <Route path="/" element={<Navigate to="/home" />} /> <Route errorElement={<ErrorBoundary />} element={<Layout />}> - <Route index path="/home" element={<Home />} /> + <Route index path="/" element={<Search />} /> <Route path="/search" element={<Search />} /> <Route path="/profile" element={<Profile />} /> + <Route path="/about" element={<About />} /> + <Route element={<Search />} /> </Route> </> ) diff --git a/src/Utils.js b/src/Utils.js index 7042c6f617d75d0a7399417233982f6ab7a26011..f3f6d528255101474bee5203d8afee2a7c136817 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -8,12 +8,6 @@ export class SearchField { } } -export const getLoginUrl = () => { - return process.env.REACT_APP_IN_SYLVA_LOGIN_PORT - ? `${process.env.REACT_APP_IN_SYLVA_LOGIN_HOST}:${process.env.REACT_APP_IN_SYLVA_LOGIN_PORT}` - : `${window._env_.REACT_APP_IN_SYLVA_LOGIN_HOST}`; -}; - export const removeNullFields = (standardFields) => { let standardFieldsNoNull = standardFields; let nullIndex; @@ -47,28 +41,32 @@ export const getSections = (fields) => { }; export const getFieldsBySection = (fields, fieldSection) => { + if (!fieldSection) { + return []; + } + let filteredFields = []; fields.forEach((field) => { if ( field.field_name && field.field_name.split('.', 1)[0].replace(/_|\./g, ' ') === fieldSection.label ) { - if (field.sources.length) { - filteredFields.push({ - label: field.field_name - .substring(field.field_name.indexOf('.') + 1) - .replace(/_|\./g, ' '), - color: 'danger', - }); - } else { - filteredFields.push({ - label: field.field_name - .substring(field.field_name.indexOf('.') + 1) - .replace(/_|\./g, ' '), - color: 'primary', - }); - } + // if (field.sources.length) { + // filteredFields.push({ + // label: field.field_name + // .substring(field.field_name.indexOf('.') + 1) + // .replace(/_|\./g, ' '), + // color: 'danger', + // }); + // } else { + filteredFields.push({ + label: field.field_name + .substring(field.field_name.indexOf('.') + 1) + .replace(/_|\./g, ' '), + color: 'primary', + }); } + //} }); return filteredFields; }; @@ -163,7 +161,6 @@ const splitString = (str, bracketOpenRegex, bracketCloseRegex, separatorRegex) = let startSplitIndex = 0; let splitStr = []; for (let i = 0; i < requestTokens.length; i++) { - requestTokens.forEach((token, index) => {}); switch (true) { case bracketOpenRegex.test(requestTokens[i]): openBracketCount = openBracketCount + 1; @@ -237,7 +234,7 @@ const buildDslClause = (clause, fields) => { .trim(); tmpQuery = `${tmpQuery} { "${firstValue}": { "gte": "${secondValue}" } } }, `; break; - default: + default: { let fieldName = splitClause[i] .substring(0, splitClause[i].indexOf('=') - 1) .trim(); @@ -248,15 +245,17 @@ const buildDslClause = (clause, fields) => { case 'Numeric': tmpQuery = `${tmpQuery} { "term": { "${fieldName}": "${splitClause[i].substring(splitClause[i].indexOf('=') + 1, splitClause[i].length).trim()}" } }, `; break; - default: + default: { let values = splitClause[i] .substring(splitClause[i].indexOf('=') + 1, splitClause[i].length) .split(','); values.forEach((value) => { tmpQuery = `${tmpQuery} { "match": { "${fieldName}": { "query": "${value.trim()}", "operator": "AND" } } }, `; }); + } } } + } } } if (tmpQuery.endsWith(', ')) { @@ -268,7 +267,7 @@ const buildDslClause = (clause, fields) => { // const buildDslQuery = (request, query, fields) => { // let tmpQuery = query -const buildDslQuery = (request, fields) => { +export const buildDslQuery = (request, fields) => { let requestStr = request.replace(/ OR /g, ' | ').replace(/ AND /g, ' & ').trim(); let splitRequest = splitString(requestStr, /\(/, /\)/, /&|\|/); let tmpQuery = ''; @@ -361,70 +360,6 @@ export const createAdvancedQueriesBySource = ( const indicesLists = []; const publicFieldnames = []; const privateFields = []; - /* fields.forEach(field => { - if (field.ispublic) { - publicFieldnames.push(field.field_name) - } else { - privateFields.push(field) - } - }) - - if (selectedSources.length) { - selectedSources.forEach(source => { - queriedSourcesId.push(source.id) - }) - } else { - sources.forEach(source => { - queriedSourcesId.push(source.id) - }) - } - - noPolicySourcesId = queriedSourcesId - - //browse fields to create a map containing the fields available by source - //ie : [field1, field2] - [source1], [field1, field3, field4] - [source2], [field1] - [] - //empty brackets means "the sources not affected by policies" at that point - this list is being compiled at the same time - if (privateFields.length) { - privateFields.forEach(field => { - field.sources.forEach(sourceId => { - if (queriedSourcesId.includes(sourceId)) { - const index = findArray(sourcesLists, [sourceId]) - if (index >= 0) { - fieldsLists[index].push(field.field_name) - } else { - fieldsLists.push([field.field_name]) - sourcesLists.push([sourceId]) - } - } - //filter no policy sources - if (noPolicySourcesId.includes(sourceId)) - noPolicySourcesId = removeArrayElement(noPolicySourcesId, noPolicySourcesId.indexOf(sourceId)) - }) - }) - //add the public fields for every source - fieldsLists.forEach(fieldList => { - fieldList.push(...publicFieldnames) - }) - } - - const indexOfAllSources = findArray(sourcesLists, []) - //if there isn't any source with no policy, remove corresponding items in sourcesLists and fieldsLists - if (!noPolicySourcesId.length) { - sourcesLists = removeArrayElement(sourcesLists, indexOfAllSources) - fieldsLists = removeArrayElement(fieldsLists, indexOfAllSources) - } else { - sourcesLists = updateArrayElement(sourcesLists, indexOfAllSources, noPolicySourcesId) - } - - //get elastic indices from sources id - sourcesLists.forEach(sourceList => { - const indicesList = [] - sourceList.forEach(sourceId => { - const source = sources.find(src => src.id === sourceId) - indicesList.push(source.index_id) - }) - indicesLists.push(indicesList) - }) */ fields.forEach((field) => { if (field.ispublic) { @@ -517,104 +452,3 @@ export const createAdvancedQueriesBySource = ( }); return queries; }; - -export const createBasicQueriesBySource = ( - fields, - searchRequest, - selectedSources, - sources -) => { - const queries = []; - let fieldsLists = [[]]; - let sourcesLists = [[]]; - let queriedSourcesId = []; - let noPolicySourcesId = []; - const indicesLists = []; - const publicFieldnames = []; - const privateFields = []; - - fields.forEach((field) => { - if (field.ispublic) { - publicFieldnames.push(field.field_name); - } else { - privateFields.push(field); - } - }); - - if (selectedSources.length) { - selectedSources.forEach((source) => { - queriedSourcesId.push(source.id); - }); - } else { - if (sources.length) { - sources.forEach((source) => { - queriedSourcesId.push(source.id); - }); - } - } - - noPolicySourcesId = queriedSourcesId; - - //browse fields to create a map containing the fields available by source - //ie : [field1, field2] - [source1], [field1, field3, field4] - [source2], [field1] - [] - //empty brackets means "the sources not affected by policies" at that point - if (privateFields.length) { - privateFields.forEach((field) => { - field.sources.forEach((sourceId) => { - if (queriedSourcesId.includes(sourceId)) { - const index = findArray(sourcesLists, [sourceId]); - if (index >= 0) { - fieldsLists[index].push(field.field_name); - } else { - fieldsLists.push([field.field_name]); - sourcesLists.push([sourceId]); - } - } - //filter no policy sources - if (noPolicySourcesId.includes(sourceId)) - noPolicySourcesId = removeArrayElement( - noPolicySourcesId, - noPolicySourcesId.indexOf(sourceId) - ); - }); - }); - //add the public fields for every source - fieldsLists.forEach((fieldList) => { - fieldList.push(...publicFieldnames); - }); - } - - const indexOfAllSources = findArray(sourcesLists, []); - //if there is no source with no policy, remove corresponding items in sourcesLists and fieldsLists - if (!noPolicySourcesId.length) { - sourcesLists = removeArrayElement(sourcesLists, indexOfAllSources); - fieldsLists = removeArrayElement(fieldsLists, indexOfAllSources); - } else { - sourcesLists = updateArrayElement(sourcesLists, indexOfAllSources, noPolicySourcesId); - fieldsLists = updateArrayElement(fieldsLists, indexOfAllSources, publicFieldnames); - } - - //get elastic indices from sources id - sourcesLists.forEach((sourceList) => { - const indicesList = []; - sourceList.forEach((sourceId) => { - const source = sources.find((src) => src.id === sourceId); - indicesList.push(source.index_id); - }); - indicesLists.push(indicesList); - }); - - sourcesLists.forEach((sourcesArray, index) => { - let sourceParam = `"_source": [`; - 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}", "operator": "AND", "type": "cross_fields" } } }`; - queries.push({ indicesId: indicesLists[index], query: JSON.parse(query) }); - }); - return queries; -}; diff --git a/src/actions/index.js b/src/actions/index.js deleted file mode 100644 index 245f568201757c06ee57c2d7c5ba4c3850037dab..0000000000000000000000000000000000000000 --- a/src/actions/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import * as user from './user'; -export { user }; diff --git a/src/actions/source.js b/src/actions/source.js deleted file mode 100644 index cd208f5527c2288ee9efe5417e86370af3434bdd..0000000000000000000000000000000000000000 --- a/src/actions/source.js +++ /dev/null @@ -1,61 +0,0 @@ -import { InSylvaSourceManagerClient } from '../context/InSylvaSourceManagerClient'; -import { InSylvaSearchClient } from '../context/InSylvaSearchClient'; -import { refreshToken } from '../context/UserContext'; -import { tokenTimedOut } from '../Utils'; - -const ismClient = new InSylvaSourceManagerClient(); -ismClient.baseUrl = process.env.REACT_APP_IN_SYLVA_SOURCE_MANAGER_PORT - ? `${process.env.REACT_APP_IN_SYLVA_SOURCE_MANAGER_HOST}:${process.env.REACT_APP_IN_SYLVA_SOURCE_MANAGER_PORT}` - : `${window._env_.REACT_APP_IN_SYLVA_SOURCE_MANAGER_HOST}`; -const isClient = new InSylvaSearchClient(); -isClient.baseUrl = process.env.REACT_APP_IN_SYLVA_SEARCH_PORT - ? `${process.env.REACT_APP_IN_SYLVA_SEARCH_HOST}:${process.env.REACT_APP_IN_SYLVA_SEARCH_PORT}` - : `${window._env_.REACT_APP_IN_SYLVA_SEARCH_HOST}`; - -export { - fetchPublicFields, - fetchUserPolicyFields, - fetchSources, - searchQuery, - getQueryCount, -}; - -async function fetchPublicFields() { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - refreshToken(); - } - ismClient.token = sessionStorage.getItem('access_token'); - return await ismClient.publicFields(); -} - -async function fetchUserPolicyFields(kcId) { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - refreshToken(); - } - ismClient.token = sessionStorage.getItem('access_token'); - return await ismClient.userPolicyFields(kcId); -} - -async function fetchSources(kcId) { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - refreshToken(); - } - ismClient.token = sessionStorage.getItem('access_token'); - return await ismClient.sourcesWithIndexes(kcId); -} - -async function searchQuery(query) { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - refreshToken(); - } - isClient.token = sessionStorage.getItem('access_token'); - return await isClient.search(query); -} - -async function getQueryCount(queries) { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - refreshToken(); - } - isClient.token = sessionStorage.getItem('access_token'); - return await isClient.count(queries); -} diff --git a/src/actions/user.js b/src/actions/user.js deleted file mode 100644 index d5cc589c313b89b63005dedefc395326a0b95d25..0000000000000000000000000000000000000000 --- a/src/actions/user.js +++ /dev/null @@ -1,216 +0,0 @@ -import { InSylvaGatekeeperClient } from '../context/InSylvaGatekeeperClient'; -import { refreshToken } from '../context/UserContext'; -import { tokenTimedOut } from '../Utils'; - -const igClient = new InSylvaGatekeeperClient(); -igClient.baseUrl = process.env.REACT_APP_IN_SYLVA_GATEKEEPER_PORT - ? `${process.env.REACT_APP_IN_SYLVA_GATEKEEPER_HOST}:${process.env.REACT_APP_IN_SYLVA_GATEKEEPER_PORT}` - : `${window._env_.REACT_APP_IN_SYLVA_GATEKEEPER_HOST}`; - -export const findOneUser = async (id, request = igClient) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const user = await request.findOneUser(id); - if (user) { - return user; - } - } catch (error) { - console.error(error); - } -}; - -export const findOneUserWithGroupAndRole = async (kcId) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const user = await igClient.findOneUserWithGroupAndRole(kcId); - if (user) { - return user; - } - } catch (error) { - console.error(error); - } -}; - -export const getGroups = async () => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const groups = await igClient.getGroups(); - if (groups) { - return groups; - } - } catch (error) { - console.error(error); - } -}; - -export const getRoles = async () => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const roles = await igClient.findRole(); - if (roles) { - return roles; - } - } catch (error) { - console.error(error); - } -}; - -export const sendMail = async (subject, message, request = igClient) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - await request.sendMail(subject, message); - } catch (error) { - console.error(error); - } -}; - -export const fetchUserDetails = async (kcId) => { - try { - return await igClient.getUserDetails(kcId); - } catch (error) { - console.error(error); - } -}; - -export const fetchUserRequests = async (kcId) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const requests = await igClient.getUserRequests(kcId); - if (requests) { - return requests; - } - } catch (error) { - console.error(error); - } -}; - -export const createUserRequest = async (kcId, message) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - return await igClient.createUserRequest(kcId, message); - } catch (error) { - console.error(error); - } -}; - -export const deleteUserRequest = async (requestId) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - return await igClient.deleteUserRequest(requestId); - } catch (error) { - console.error(error); - } -}; - -export const addUserHistory = async (kcId, query, name, uiStructure, description) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const jsonUIStructure = JSON.stringify(uiStructure); - return await igClient.addUserHistory(kcId, query, name, jsonUIStructure, description); - } catch (error) { - console.error(error); - } -}; - -export const fetchUserHistory = async (kcId) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const history = await igClient.userHistory(kcId); - if (history) { - return history; - } - } catch (error) { - console.error(error); - } -}; - -export const deleteUserHistory = async (id) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - await igClient.deleteUserHistory(id); - } catch (error) { - console.error(error); - } -}; - -export const fetchUserFieldsDisplaySettings = async (userId) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - const row = await igClient.fetchUserFieldsDisplaySettings(userId); - return row.std_fields_ids; - } catch (error) { - console.error(error); - } -}; - -export const createUserFieldsDisplaySettings = async (userId, stdFieldsIds) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - return await igClient.createUserFieldsDisplaySettings(userId, stdFieldsIds); - } catch (error) { - console.error(error); - } -}; - -export const updateUserFieldsDisplaySettings = async (userId, stdFieldsIds) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - return await igClient.updateUserFieldsDisplaySettings(userId, stdFieldsIds); - } catch (error) { - console.error(error); - } -}; - -export const deleteUserFieldsDisplaySettings = async (userId) => { - if (tokenTimedOut(process.env.REACT_KEYCLOAK_TOKEN_VALIDITY)) { - await refreshToken(); - } - igClient.token = sessionStorage.getItem('access_token'); - try { - return await igClient.deleteUserFieldsDisplaySettings(userId); - } catch (error) { - console.error(error); - } -}; diff --git a/src/components/Header/Header.js b/src/components/Header/Header.js index 2e653c1e436f4ce3b471ba68ea66ffbd21789552..700f8fae776eb6f226c22737a3ff84f37cb60bdb 100644 --- a/src/components/Header/Header.js +++ b/src/components/Header/Header.js @@ -1,49 +1,108 @@ -import React from 'react'; -import { NavLink } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; import { EuiHeader, + EuiHeaderLink, + EuiHeaderLinks, + EuiHeaderLogo, EuiHeaderSection, EuiHeaderSectionItem, - EuiHeaderLinks, - EuiHeaderLink, + EuiLink, + EuiSpacer, + EuiToolTip, } from '@elastic/eui'; import HeaderUserMenu from './HeaderUserMenu'; import style from './styles'; import logoInSylva from '../../assets/favicon.svg'; import { useTranslation } from 'react-i18next'; import LanguageSwitcher from '../LanguageSwitcher/LanguageSwitcher'; +import { useGatekeeper } from '../../contexts/GatekeeperContext'; +import { useUserInfo } from '../../contexts/TokenContext'; +import { NavLink } from 'react-router-dom'; const routes = [ { id: 0, - label: 'home', - href: '/home', + label: 'search', + href: '/search', }, { id: 1, - label: 'search', - href: '/search', + label: 'about', + href: '/about', }, ]; const Header = () => { const { t } = useTranslation(['header', 'common']); + const client = useGatekeeper(); + const getUserInfo = useUserInfo(); + const [userRoleId, setUserRoleId] = useState(0); + + useEffect(() => { + const fetchUserRoleID = async () => { + const { sub } = await getUserInfo(); + const user = await client.findUserBySub(sub); + if (!user.id) { + return; + } + const { roles } = user; + return roles[0]?.role?.id; + }; + + fetchUserRoleID().then((userRoleID) => { + setUserRoleId(userRoleID); + }); + }, []); + + const PortalLink = () => { + if (userRoleId >= 1) { + return ( + <EuiHeaderSectionItem> + <EuiHeaderLink> + <EuiToolTip position="bottom" content={t('header:portalLink.tooltip')}> + <EuiLink + href={process.env.REACT_APP_IN_SYLVA_PORTAL_BASE_URL} + target="_blank" + rel="noopener noreferrer" + > + {t(`header:portalLink.title`)} + </EuiLink> + </EuiToolTip> + </EuiHeaderLink> + <EuiSpacer size={'s'} /> + </EuiHeaderSectionItem> + ); + } + }; return ( <EuiHeader> <EuiHeaderSection grow={true}> - <EuiHeaderSectionItem> - <img style={style.logo} src={logoInSylva} alt={t('common:inSylvaLogoAlt')} /> + <EuiHeaderSectionItem border={'none'}> + <EuiHeaderLogo + iconType={() => { + return ( + <img + style={style.logo} + src={logoInSylva} + alt={t('common:inSylvaLogoAlt')} + /> + ); + }} + href="#/" + aria-label={t('header:homepageRedirect')} + /> </EuiHeaderSectionItem> <EuiHeaderLinks> {routes.map((link) => ( - <EuiHeaderLink key={link.id}> - <NavLink to={link.href}>{t(`tabs.${link.label}`)}</NavLink> + <EuiHeaderLink key={link.id} size={'m'}> + <NavLink to={link.href}>{t(`header:tabs.${link.label}`)}</NavLink> </EuiHeaderLink> ))} </EuiHeaderLinks> </EuiHeaderSection> <EuiHeaderSection side="right"> + <PortalLink /> <EuiHeaderSectionItem style={style.languageSwitcherItem} border={'none'}> <LanguageSwitcher /> </EuiHeaderSectionItem> diff --git a/src/components/Header/HeaderUserMenu.js b/src/components/Header/HeaderUserMenu.js index 0b4a4a96d621d1af9ecf2d2ff92b5afb946864ec..cbf3f374c30f4cd29b4e201b320adc4c822b9ea2 100644 --- a/src/components/Header/HeaderUserMenu.js +++ b/src/components/Header/HeaderUserMenu.js @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { useAuth } from 'oidc-react'; import { EuiAvatar, EuiFlexGroup, @@ -8,11 +9,11 @@ import { EuiPopover, EuiButtonIcon, } from '@elastic/eui'; -import { signOut } from '../../context/UserContext'; import { useTranslation } from 'react-i18next'; import { NavLink } from 'react-router-dom'; const HeaderUserMenu = () => { + const auth = useAuth(); const { t } = useTranslation('header'); const [isOpen, setIsOpen] = useState(false); const [username, setUsername] = useState(''); @@ -26,7 +27,7 @@ const HeaderUserMenu = () => { }; useEffect(() => { - setUsername(sessionStorage.getItem('username')); + setUsername(auth.userData?.profile?.preferred_username || ''); }, []); const HeaderUserButton = ( @@ -63,7 +64,7 @@ const HeaderUserMenu = () => { </NavLink> </EuiFlexItem> <EuiFlexItem grow={false}> - <NavLink to={'/'} onClick={() => signOut()}> + <NavLink to={'/'} onClick={() => auth.signOutRedirect()}> {t('header:userMenu.logOutButton')} </NavLink> </EuiFlexItem> diff --git a/src/components/Header/styles.js b/src/components/Header/styles.js index 94f8ecf22cfa183f26e33b3f3c7ae6086c4730ee..1ccde11a8f7ec3bc8b52a54e1585dd3eb997e9a5 100644 --- a/src/components/Header/styles.js +++ b/src/components/Header/styles.js @@ -2,7 +2,7 @@ const headerStyle = { logo: { width: '75px', height: '75px', - padding: '10px', + padding: '12px', }, languageSwitcherItem: { margin: '10px', diff --git a/src/components/Layout/Layout.js b/src/components/Layout/Layout.js index 087c5ce116b09f9ca0c8e5bfe70c16f0a5825fa2..743a15b477ba72cee8cd3df4cb45ae7290c1619c 100644 --- a/src/components/Layout/Layout.js +++ b/src/components/Layout/Layout.js @@ -1,15 +1,14 @@ import React from 'react'; import { Outlet } from 'react-router-dom'; import Header from '../../components/Header'; -import { EuiPage, EuiPageBody, EuiPageSection } from '@elastic/eui'; -import styles from './styles.js'; +import { EuiFlexGroup, EuiPage, EuiPageBody, EuiPageSection } from '@elastic/eui'; import { Slide, ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; const Layout = () => { return ( - <EuiPage style={styles.page} restrictWidth={false}> - <EuiPageBody> + <EuiFlexGroup style={{ minHeight: '100vh' }}> + <EuiPage direction={'column'}> <ToastContainer position="bottom-right" autoClose={5000} @@ -23,12 +22,14 @@ const Layout = () => { theme="light" transition={Slide} /> - <Header /> - <EuiPageSection style={styles.pageContent} grow={true}> - <Outlet /> - </EuiPageSection> - </EuiPageBody> - </EuiPage> + <EuiPageBody> + <Header /> + <EuiPageSection grow={true} color="plain"> + <Outlet /> + </EuiPageSection> + </EuiPageBody> + </EuiPage> + </EuiFlexGroup> ); }; diff --git a/src/components/Layout/styles.js b/src/components/Layout/styles.js deleted file mode 100644 index fd435cba3022f538dbb0d55ee19b69aee634fe41..0000000000000000000000000000000000000000 --- a/src/components/Layout/styles.js +++ /dev/null @@ -1,10 +0,0 @@ -const styles = { - page: { - padding: '0px', - }, - pageContent: { - backgroundColor: '#ffffff', - }, -}; - -export default styles; diff --git a/src/components/NoResultEmptyPrompt/NoResultEmptyPrompt.js b/src/components/NoResultEmptyPrompt/NoResultEmptyPrompt.js new file mode 100644 index 0000000000000000000000000000000000000000..1685cca1c021f6f8e40b7b71632d2dfc9e0119ac --- /dev/null +++ b/src/components/NoResultEmptyPrompt/NoResultEmptyPrompt.js @@ -0,0 +1,54 @@ +import React from 'react'; +import { + EuiButton, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiEmptyPrompt, + EuiFlexGroup, +} from '@elastic/eui'; +import { useTranslation } from 'react-i18next'; + +const NoResultEmptyPrompt = ({ setSelectedTabNumber }) => { + const { t } = useTranslation('results'); + + const body = ( + <EuiDescriptionList compressed> + <EuiDescriptionListTitle> + {t('results:noResult.body.title')} + </EuiDescriptionListTitle> + <EuiDescriptionListDescription> + {t('results:noResult.body.description')} + </EuiDescriptionListDescription> + </EuiDescriptionList> + ); + + const actions = [ + <EuiButton + onClick={() => { + setSelectedTabNumber(0); + }} + color="primary" + fill + key={0} + iconType={'pencil'} + > + {t('results:noResult.action')} + </EuiButton>, + ]; + + return ( + <EuiFlexGroup style={{ minHeight: '75vh' }}> + <EuiEmptyPrompt + title={<h2>{t('results:noResult.title')}</h2>} + titleSize={'l'} + iconType={'magnifyWithExclamation'} + color={'plain'} + body={body} + actions={actions} + /> + </EuiFlexGroup> + ); +}; + +export default NoResultEmptyPrompt; diff --git a/src/components/ToastMessage/ToastMessage.js b/src/components/ToastMessage/ToastMessage.js index 72e024d07d7a6a01f54f44ec2877ee48880afc61..0be043919bf7bad0d33e98000b2cf1589d48ded9 100644 --- a/src/components/ToastMessage/ToastMessage.js +++ b/src/components/ToastMessage/ToastMessage.js @@ -1,5 +1,5 @@ import React from 'react'; -import { EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; const ToastMessage = ({ title, message }) => { return ( @@ -8,7 +8,9 @@ const ToastMessage = ({ title, message }) => { <p>{title}</p> </EuiTitle> <EuiSpacer size={'m'} /> - <p>{message}</p> + <EuiText> + <p>{message}</p> + </EuiText> </> ); }; diff --git a/src/context/InSylvaGatekeeperClient.js b/src/context/InSylvaGatekeeperClient.js deleted file mode 100644 index 26f10787d4e09845938d541d741098a20cbafea1..0000000000000000000000000000000000000000 --- a/src/context/InSylvaGatekeeperClient.js +++ /dev/null @@ -1,138 +0,0 @@ -class InSylvaGatekeeperClient { - async post(method, path, requestContent) { - const headers = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.token}`, - }; - const response = await fetch(`${this.baseUrl}${path}`, { - method, - headers, - body: JSON.stringify(requestContent), - mode: 'cors', - }); - return await response.json(); - } - - async getGroups() { - const path = `/user/groups`; - return await this.post('POST', `${path}`, {}); - } - - async getUserRequests(kcId) { - const path = `/user/list-requests-by-user`; - return await this.post('POST', `${path}`, { - kcId, - }); - } - - async createUserRequest(kcId, message) { - const path = `/user/create-request`; - return await this.post('POST', `${path}`, { - kcId, - message, - }); - } - - async deleteUserRequest(id) { - const path = `/user/delete-request`; - return await this.post('DELETE', `${path}`, { id }); - } - - async findRole() { - const path = `/role/find`; - return await this.post('GET', `${path}`); - } - - async kcId({ email }) { - const path = `/user/kcid`; - return await this.post('POST', `${path}`, { - email, - }); - } - - async sendMail(subject, message) { - const path = `/user/send-mail`; - - await this.post('POST', `${path}`, { - subject, - message, - }); - } - - async findOneUser(id) { - const path = `/user/findOne`; - return await this.post('POST', `${path}`, { - id, - }); - } - - // Returns an array containing objects for each user group. - async findOneUserWithGroupAndRole(kcId) { - const path = `/user/one-with-groups-and-roles`; - return await this.post('POST', `${path}`, { - id: kcId, - }); - } - - async getUserDetails(kcId) { - const path = `/user/detail`; - return await this.post('POST', `${path}`, { - kcId, - }); - } - - async addUserHistory(kcId, query, name, uiStructure, description) { - const path = `/user/add-history`; - return await this.post('POST', `${path}`, { - kcId, - query, - name, - uiStructure, - description, - }); - } - - async userHistory(kcId) { - const path = `/user/fetch-history`; - return await this.post('POST', `${path}`, { - kcId, - }); - } - - async deleteUserHistory(id) { - const path = `/user/delete-history`; - await this.post('POST', `${path}`, { - id, - }); - } - - // GET Fetches user fields display settings - async fetchUserFieldsDisplaySettings(userId) { - return await this.post('GET', `/user/fields-display-settings/${userId}`); - } - - // POST Creates user fields display settings - async createUserFieldsDisplaySettings(userId, stdFieldsIds) { - return await this.post('POST', `/user/fields-display-settings/`, { - userId, - stdFieldsIds, - }); - } - - // PUT Updates user fields display settings - async updateUserFieldsDisplaySettings(userId, stdFieldsIds) { - return await this.post('PUT', `/user/fields-display-settings/`, { - userId, - stdFieldsIds, - }); - } - - // DELETE Remove user fields display settings - async deleteUserFieldsDisplaySettings(userId) { - return await this.post('DELETE', `/user/fields-display-settings/${userId}`); - } -} - -InSylvaGatekeeperClient.prototype.baseUrl = null; -InSylvaGatekeeperClient.prototype.token = null; -export { InSylvaGatekeeperClient }; diff --git a/src/context/InSylvaKeycloakClient.js b/src/context/InSylvaKeycloakClient.js deleted file mode 100644 index 092f27ca9a1c0e195a51c390533a08fd802107f1..0000000000000000000000000000000000000000 --- a/src/context/InSylvaKeycloakClient.js +++ /dev/null @@ -1,66 +0,0 @@ -import { getLoginUrl } from '../Utils'; - -class InSylvaKeycloakClient { - async post(path, requestContent) { - 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" - }; - let formBody = []; - for (const property in requestContent) { - const encodedKey = encodeURIComponent(property); - const encodedValue = encodeURIComponent(requestContent[property]); - formBody.push(encodedKey + '=' + encodedValue); - } - formBody = formBody.join('&'); - const response = await fetch(`${this.baseUrl}${path}`, { - method: 'POST', - headers, - body: formBody, - mode: 'cors', - }); - if (!response.ok) { - await this.logout(); - sessionStorage.removeItem('kcId'); - sessionStorage.removeItem('access_token'); - sessionStorage.removeItem('refresh_token'); - window.location.replace(getLoginUrl() + '?requestType=search'); - } - if (response.statusText !== 'No Content') { - return await response.json(); - } - } - - async refreshToken({ - realm = this.realm, - client_id = this.client_id, - grant_type = 'refresh_token', - refresh_token, - }) { - const path = `/auth/realms/${realm}/protocol/openid-connect/token`; - const token = await this.post(`${path}`, { - client_id, - grant_type, - refresh_token, - }); - return { token }; - } - - async logout() { - const refresh_token = sessionStorage.getItem('refresh_token'); - if (refresh_token) { - const client_id = this.client_id; - await this.post(`/auth/realms/${this.realm}/protocol/openid-connect/logout`, { - client_id, - refresh_token, - }); - } - } -} - -InSylvaKeycloakClient.prototype.baseUrl = null; -InSylvaKeycloakClient.prototype.client_id = null; -InSylvaKeycloakClient.prototype.grant_type = null; -InSylvaKeycloakClient.prototype.realm = null; -export { InSylvaKeycloakClient }; diff --git a/src/context/InSylvaSearchClient.js b/src/context/InSylvaSearchClient.js deleted file mode 100644 index ffdc831b72ef715a652caadace18b8e3f2c09730..0000000000000000000000000000000000000000 --- a/src/context/InSylvaSearchClient.js +++ /dev/null @@ -1,67 +0,0 @@ -class InSylvaSearchClient { - async post(method, path, requestContent) { - const headers = { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - Authorization: `Bearer ${this.token}`, - 'Access-Control-Max-Age': 86400, - // 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH', - }; - const response = await fetch(`${this.baseUrl}${path}`, { - method, - headers, - body: JSON.stringify(requestContent), - mode: 'cors', - }); - return await response.json(); - } - - /* async search(query, indices) { - const indicesId = [] - indices.forEach(index => { - indicesId.push(index.index_id) - }) - const path = `/search`; - const result = await this.post('POST', `${path}`, { - indicesId, - query - }); - return result; - } */ - - async search(queries) { - let finalResult = []; - - for (let i = 0; i < queries.length; i++) { - const indicesId = queries[i].indicesId; - const query = queries[i].query; - const result = await this.post('POST', '/scroll-search', { - indicesId, - query, - }); - if (!result.statusCode) { - finalResult.push(...result); - } - } - return finalResult; - } - - async count(queries) { - let finalResult = 0; - for (let i = 0; i < queries.length; i++) { - const indicesId = queries[i].indicesId; - const query = queries[i].query; - const path = `/count`; - const result = await this.post('POST', `${path}`, { - indicesId, - query, - }); - finalResult = finalResult + result.count; - } - return finalResult; - } -} - -InSylvaSearchClient.prototype.baseUrl = null; -InSylvaSearchClient.prototype.token = null; -export { InSylvaSearchClient }; diff --git a/src/context/InSylvaSourceManagerClient.js b/src/context/InSylvaSourceManagerClient.js deleted file mode 100644 index ceffbd624da8af86e2555a72ab9afdea255a7985..0000000000000000000000000000000000000000 --- a/src/context/InSylvaSourceManagerClient.js +++ /dev/null @@ -1,34 +0,0 @@ -class InSylvaSourceManagerClient { - async post(method, path, requestContent) { - const headers = { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.token}`, - }; - const response = await fetch(`${this.baseUrl}${path}`, { - method, - headers, - body: JSON.stringify(requestContent), - mode: 'cors', - }); - return await response.json(); - } - - async publicFields() { - const path = `/publicFieldList`; - return this.post('GET', `${path}`); - } - - async userPolicyFields(userId) { - const path = `/policy-stdfields`; - return this.post('POST', `${path}`, { userId }); - } - - async sourcesWithIndexes(kc_id) { - const path = `/source_indexes`; - return this.post('POST', `${path}`, { kc_id }); - } -} - -InSylvaSourceManagerClient.prototype.baseUrl = null; -InSylvaSourceManagerClient.prototype.token = null; -export { InSylvaSourceManagerClient }; diff --git a/src/context/UserContext.js b/src/context/UserContext.js deleted file mode 100644 index 1b6d8b7946bbdc57a1abc0f3cee6a1115674ed4b..0000000000000000000000000000000000000000 --- a/src/context/UserContext.js +++ /dev/null @@ -1,141 +0,0 @@ -import React, { createContext, useContext, useReducer } from 'react'; -import { InSylvaGatekeeperClient } from './InSylvaGatekeeperClient'; -import { InSylvaKeycloakClient } from './InSylvaKeycloakClient'; -import { getLoginUrl } from '../Utils'; -import { fetchUserDetails, findOneUser } from '../actions/user'; - -const UserStateContext = createContext(null); -const UserDispatchContext = createContext(null); - -const igClient = new InSylvaGatekeeperClient(); -igClient.baseUrl = process.env.REACT_APP_IN_SYLVA_GATEKEEPER_PORT - ? `${process.env.REACT_APP_IN_SYLVA_GATEKEEPER_HOST}:${process.env.REACT_APP_IN_SYLVA_GATEKEEPER_PORT}` - : `${window._env_.REACT_APP_IN_SYLVA_GATEKEEPER_HOST}`; - -const ikcClient = new InSylvaKeycloakClient(); -ikcClient.baseUrl = process.env.REACT_APP_IN_SYLVA_KEYCLOAK_PORT - ? `${process.env.REACT_APP_IN_SYLVA_KEYCLOAK_HOST}:${process.env.REACT_APP_IN_SYLVA_KEYCLOAK_PORT}` - : `${window._env_.REACT_APP_IN_SYLVA_KEYCLOAK_HOST}`; -ikcClient.realm = process.env.REACT_APP_IN_SYLVA_KEYCLOAK_PORT - ? `${process.env.REACT_APP_IN_SYLVA_REALM}` - : `${window._env_.REACT_APP_IN_SYLVA_REALM}`; -ikcClient.client_id = process.env.REACT_APP_IN_SYLVA_KEYCLOAK_PORT - ? `${process.env.REACT_APP_IN_SYLVA_CLIENT_ID}` - : `${window._env_.REACT_APP_IN_SYLVA_CLIENT_ID}`; -ikcClient.grant_type = process.env.REACT_APP_IN_SYLVA_KEYCLOAK_PORT - ? `${process.env.REACT_APP_IN_SYLVA_GRANT_TYPE}` - : `${window._env_.REACT_APP_IN_SYLVA_GRANT_TYPE}`; - -function userReducer(state, action) { - switch (action.type) { - case 'USER_LOGGED_IN': - return { ...state, isAuthenticated: true }; - case 'SIGN_OUT_SUCCESS': - return { ...state, isAuthenticated: false }; - case 'EXPIRED_SESSION': - return { ...state, isAuthenticated: false }; - case 'USER_NOT_LOGGED_IN': - return { ...state, isAuthenticated: false }; - case 'STD_FIELDS_SUCCESS': - return { ...state, isAuthenticated: true }; - case 'STD_FIELDS_FAILURE': - return { ...state, isAuthenticated: true }; - default: { - throw new Error(`Unhandled action type: ${action.type}`); - } - } -} - -function UserProvider({ children }) { - const [state, dispatch] = useReducer(userReducer, { - isAuthenticated: !!sessionStorage.getItem('access_token'), - }); - - return ( - <UserStateContext.Provider value={state}> - <UserDispatchContext.Provider value={dispatch}> - {children} - </UserDispatchContext.Provider> - </UserStateContext.Provider> - ); -} - -function useUserState() { - const context = useContext(UserStateContext); - if (context === undefined) { - throw new Error('useUserState must be used within a UserProvider'); - } - return context; -} - -function useUserDispatch() { - const context = useContext(UserDispatchContext); - if (context === undefined) { - throw new Error('useUserDispatch must be used within a UserProvider'); - } - return context; -} - -const getUserIdFromKcId = async (kcId) => { - if (!kcId) { - return; - } - const userDetails = await fetchUserDetails(kcId); - if (!userDetails) { - return; - } - return userDetails.id; -}; - -const checkUserLogin = async (kcId, accessToken, refreshToken) => { - if (!kcId || !accessToken || !refreshToken) { - return; - } - const userId = await getUserIdFromKcId(kcId); - if (userId) { - sessionStorage.setItem('userId', userId.toString()); - } - const user = await findOneUser(kcId); - if (user) { - sessionStorage.setItem('username', user.username); - sessionStorage.setItem('email', user.email); - } - sessionStorage.setItem('kcId', kcId); - sessionStorage.setItem('access_token', accessToken); - sessionStorage.setItem('refresh_token', refreshToken); - if (!sessionStorage.getItem('token_refresh_time')) { - sessionStorage.setItem('token_refresh_time', Date.now().toString()); - } -}; - -async function refreshToken() { - if (!sessionStorage.getItem('kcId')) { - return; - } - setTimeout(async () => { - const result = await ikcClient.refreshToken({ - refresh_token: sessionStorage.getItem('refresh_token'), - }); - if (result) { - sessionStorage.setItem('access_token', result.token.access_token); - sessionStorage.setItem('token_refresh_time', Date.now().toString()); - } - }, 3000); -} - -async function signOut() { - await ikcClient.logout(); - sessionStorage.removeItem('kcId'); - sessionStorage.removeItem('access_token'); - sessionStorage.removeItem('refresh_token'); - window.location.replace(getLoginUrl() + '?requestType=search'); -} - -export { - UserProvider, - useUserState, - useUserDispatch, - checkUserLogin, - refreshToken, - signOut, -}; diff --git a/src/contexts/GatekeeperContext.js b/src/contexts/GatekeeperContext.js new file mode 100644 index 0000000000000000000000000000000000000000..9604361803d89c99d70c49c2d09856efeff51203 --- /dev/null +++ b/src/contexts/GatekeeperContext.js @@ -0,0 +1,19 @@ +import React, { createContext, useContext } from 'react'; +import { InSylvaGatekeeperClient } from '../services/GatekeeperService'; +import { useUserInfo } from './TokenContext'; + +const GatekeeperContext = createContext(null); + +export const useGatekeeper = () => { + const context = useContext(GatekeeperContext); + return context; +}; + +export const GatekeeperProvider = ({ children }) => { + const getUserInfo = useUserInfo(); + const client = new InSylvaGatekeeperClient(getUserInfo); + + return ( + <GatekeeperContext.Provider value={client}>{children}</GatekeeperContext.Provider> + ); +}; diff --git a/src/contexts/TokenContext.js b/src/contexts/TokenContext.js new file mode 100644 index 0000000000000000000000000000000000000000..dfbd78feb1229fc4e3ac5a0cfd52d02dfcd9f999 --- /dev/null +++ b/src/contexts/TokenContext.js @@ -0,0 +1,52 @@ +import React, { createContext, useContext } from 'react'; +import { useAuth } from 'oidc-react'; +import TokenService from '../services/TokenService'; + +const TokenContext = createContext(null); + +export const useUserInfo = () => { + const context = useContext(TokenContext); + return context; +}; + +export const TokenProvider = ({ children }) => { + const auth = useAuth(); + + const getUserInfo = async () => { + //console.log('Getting access token'); + if (!auth) { + //console.log('Auth not found'); + return null; + } + + if (auth.isLoading) { + //console.log('Auth is loading'); + return null; + } + + if (!auth.userData || !auth.userData.access_token) { + //console.log('User data not found'); + await auth.signOutRedirect(); + } + + const access_token = auth.userData.access_token; + //console.log(access_token); + + let userInfo = await new TokenService().getUserInfo(access_token); + if (!userInfo) { + //console.log('User info not found'); + const refreshed = await new TokenService().refreshToken( + auth.userData.refresh_token + ); + if (!refreshed) { + //console.log('Error refreshing token'); + await auth.signOutRedirect(); + } + userInfo = await new TokenService().getUserInfo(refreshed.access_token); + } + //console.log('User info:', userInfo); + return { ...userInfo, ...auth.userData }; + }; + + return <TokenContext.Provider value={getUserInfo}>{children}</TokenContext.Provider>; +}; diff --git a/src/i18n.js b/src/i18n.js index d422da216a0d038d5978aba593300f8ac89a0ec8..5f49f6ef06094da4867af6babcc095607b26ca35 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -10,7 +10,7 @@ i18n fallbackLng: 'fr', ns: 'common', defaultNS: 'common', - debug: true, + debug: false, load: 'languageOnly', interpolation: { // not needed for react as it escapes by default diff --git a/src/index.js b/src/index.js index 3944da2e6f150426cf6528521524585007933714..1e27e35b7b3dce71fb3b49e23f611229417f51df 100644 --- a/src/index.js +++ b/src/index.js @@ -1,32 +1,38 @@ import React, { Suspense } from 'react'; -import '@elastic/eui/dist/eui_theme_light.css'; -import { UserProvider, checkUserLogin } from './context/UserContext'; +import { createRoot } from 'react-dom/client'; +import { AuthProvider } from 'oidc-react'; import App from './App'; -import { getLoginUrl, getUrlParam, redirect } from './Utils.js'; import './i18n'; import Loading from './components/Loading'; -import { createRoot } from 'react-dom/client'; import { EuiProvider } from '@elastic/eui'; +import '@elastic/eui/dist/eui_theme_light.css'; +import { GatekeeperProvider } from './contexts/GatekeeperContext'; +import { TokenProvider } from './contexts/TokenContext'; -const userId = getUrlParam('kcId', ''); -const accessToken = getUrlParam('accessToken', ''); -let refreshToken = getUrlParam('refreshToken', ''); -if (refreshToken.includes('#/search')) { - refreshToken = refreshToken.substring(0, refreshToken.indexOf('#')); -} -checkUserLogin(userId, accessToken, refreshToken); - -if (sessionStorage.getItem('access_token')) { - const root = createRoot(document.getElementById('root')); - root.render( - <UserProvider> - <Suspense fallback={<Loading />}> - <EuiProvider colorMode={'light'}> - <App /> - </EuiProvider> - </Suspense> - </UserProvider> - ); -} else { - redirect(getLoginUrl() + '?requestType=search'); -} +const root = createRoot(document.getElementById('root')); +root.render( + <AuthProvider + authority={`${process.env.REACT_APP_KEYCLOAK_BASE_URL}`} + clientId={`${process.env.REACT_APP_KEYCLOAK_CLIENT_ID}`} + clientSecret={`${process.env.REACT_APP_KEYCLOAK_CLIENT_SECRET}`} + redirectUri={`${process.env.REACT_APP_BASE_URL}`} + onBeforeSignIn={() => { + const redirectUri = window.location.hash; + return { postLoginRedirect: redirectUri }; + }} + onSignIn={async (user) => { + const postLoginRedirect = user.state?.postLoginRedirect; + window.location.href = process.env.REACT_APP_BASE_URL + '/' + postLoginRedirect; + }} + > + <TokenProvider> + <GatekeeperProvider> + <Suspense fallback={<Loading />}> + <EuiProvider colorMode={'light'}> + <App /> + </EuiProvider> + </Suspense> + </GatekeeperProvider> + </TokenProvider> + </AuthProvider> +); diff --git a/src/pages/about/About.js b/src/pages/about/About.js new file mode 100644 index 0000000000000000000000000000000000000000..15a6b8f50f1a92fed15861f6075e25ae076f8fcb --- /dev/null +++ b/src/pages/about/About.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { NavLink } from 'react-router-dom'; + +const About = () => { + const { t } = useTranslation('about'); + + const abouts = t('about:abouts', { returnObjects: true }); + + return ( + <> + <EuiTitle size="m"> + <h4>{t('about:pageTitle')}</h4> + </EuiTitle> + <EuiSpacer size={'l'} /> + <EuiText> + <Trans + components={[ + <EuiLink + key={0} + href={ + 'https://entrepot.recherche.data.gouv.fr/file.xhtml?persistentId=doi:10.15454/ELXRGY/NCVTVR&version=5.1' + } + target="_blank" + rel="noopener noreferrer" + />, + <NavLink key={1} to={'/profile'} />, + ]} + > + {abouts.map((about, index) => ( + <p key={index}>{about}</p> + ))} + </Trans> + </EuiText> + </> + ); +}; + +export default About; diff --git a/src/pages/home/package.json b/src/pages/about/package.json similarity index 52% rename from src/pages/home/package.json rename to src/pages/about/package.json index ba3c514f8b3222be3c05312714553240fe234506..2c0afee0efe598fcea668f461df49788016fb45e 100644 --- a/src/pages/home/package.json +++ b/src/pages/about/package.json @@ -1,6 +1,6 @@ { - "name": "Home", + "name": "About", "version": "1.0.0", "private": true, - "main": "Home.js" + "main": "About.js" } diff --git a/src/pages/home/Home.js b/src/pages/home/Home.js deleted file mode 100644 index 43316d15cdf2bac74aebe8b145184359e4c4e6b7..0000000000000000000000000000000000000000 --- a/src/pages/home/Home.js +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { EuiTitle, EuiSpacer } from '@elastic/eui'; - -const Home = () => { - const { t } = useTranslation('home'); - - return ( - <> - <EuiTitle size="m"> - <h4>{t('home:pageTitle')}</h4> - </EuiTitle> - <EuiSpacer size={'l'} /> - <p>{t('home:searchToolDescription.part1')}</p> - <br /> - <p>{t('home:searchToolDescription.part2')}</p> - <br /> - <p> - {t('home:searchToolDescription.standardLink')} - <a - href={ - 'https://entrepot.recherche.data.gouv.fr/file.xhtml?persistentId=doi:10.15454/ELXRGY/NCVTVR&version=5.1' - } - target="_blank" - rel="noopener noreferrer" - > - https://entrepot.recherche.data.gouv.fr/file.xhtml?persistentId=doi:10.15454/ELXRGY/NCVTVR&version=5.1 - </a> - </p> - <br /> - <p>{t('home:searchToolDescription.part3')}</p> - <br /> - <p>{t('home:searchToolDescription.part4')}</p> - <br /> - <p>{t('home:searchToolDescription.part5')}</p> - <br /> - <p>{t('home:searchToolDescription.part6')}</p> - </> - ); -}; - -export default Home; diff --git a/src/pages/maps/SearchMap.js b/src/pages/maps/SearchMap.js index 709cbd3d37dd1f2bad294144c5b37aadcfa2f817..4226d8b66d637778420552ebcc6ee992ee96cf24 100644 --- a/src/pages/maps/SearchMap.js +++ b/src/pages/maps/SearchMap.js @@ -24,15 +24,19 @@ import 'ol/ol.css'; import { EuiButton, EuiButtonGroup, + EuiButtonIcon, + EuiCallOut, EuiCheckbox, EuiComboBox, + EuiEmptyPrompt, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiPanel, EuiProgress, EuiSpacer, + EuiText, EuiTitle, - EuiPanel, - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, EuiToolTip, htmlIdGenerator, } from '@elastic/eui'; @@ -98,6 +102,7 @@ const SearchMap = ({ setSelectedPointsIds, setResourceFlyoutDataFromId, setIsResourceFlyoutOpen, + setSelectedTabNumber, }) => { const { t } = useTranslation('maps'); // ref for handling zoomHelperText display @@ -108,10 +113,10 @@ const SearchMap = ({ // 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' }, + { label: t('maps:mapTools.filters.queryResults'), value: 'ResRequete' }, + { label: t('maps:mapTools.filters.regions'), value: 'regions' }, + { label: t('maps:mapTools.filters.departments'), value: 'departments' }, + { label: t('maps:mapTools.filters.sylvoEcoRegions'), value: 'sylvoEcoRegions' }, ]; const [selectedFilterOptions, setSelectedFilterOptions] = useState([filterOptions[0]]); const [zoomHelperTextFeature, setZoomHelperTextFeature] = useState( @@ -282,7 +287,7 @@ const SearchMap = ({ const zoomHelperTextStyle = new Style({ text: new Text({ font: '24px Arial', - text: t('maps:layersTable.zoomHelperText'), + text: t('maps:howTo.zoomHelperText'), fill: new Fill({ color: 'white', }), @@ -519,7 +524,7 @@ const SearchMap = ({ const pointFeature = createPointFeature( { id: result.id, - name: result?.resource?.identifier, + name: result?.resource?.title, }, proj.fromLonLat([geoPoint.longitude, geoPoint.latitude]), 3500, @@ -534,7 +539,7 @@ const SearchMap = ({ const point = searchResults.find((result) => result.id === id); if (point) { const geoPoint = point.experimental_site.geo_point; - const pointName = point.resource.identifier; + const pointName = point.resource.title; if (geoPoint && geoPoint.longitude && geoPoint.latitude) { if (!getSelectedPointsNames(selectedPointsSource).includes(pointName)) { const pointFeature = createPointFeature( @@ -677,23 +682,29 @@ const SearchMap = ({ setSelectedPointsIds(newSelectedPointsIds); }; - // Display selected points names - const SelectedPointsList = () => { - const SelectedPointItem = ({ id, name }) => { - const onUncheckButtonClick = () => { - unselectPoint(id); - }; - - const onOpenFlyoutClick = () => { - setResourceFlyoutDataFromId(id); - setIsResourceFlyoutOpen(true); - }; - - return ( - <EuiPanel paddingSize="s" hasShadow={false} hasBorder={true}> + const SelectedPointItem = ({ id, name }) => { + const onUncheckButtonClick = () => { + unselectPoint(id); + }; + + const onOpenFlyoutClick = () => { + setResourceFlyoutDataFromId(id); + setIsResourceFlyoutOpen(true); + }; + + return ( + <EuiFlexGroup alignItems={'center'}> + <EuiPanel + paddingSize="s" + hasShadow={false} + hasBorder + onClick={() => onOpenFlyoutClick()} + > <EuiFlexGroup alignItems={'center'}> <EuiFlexGroup> - <p>{name}</p> + <EuiText> + <p>{name}</p> + </EuiText> </EuiFlexGroup> <EuiToolTip position="top" @@ -704,70 +715,87 @@ const SearchMap = ({ aria-label={t('maps:selectedPointsList.actions.openResourceFlyout')} onClick={() => onOpenFlyoutClick()} color={'primary'} - display={'base'} - size={'s'} - /> - </EuiToolTip> - <EuiToolTip - position="top" - content={<p>{t('maps:selectedPointsList.actions.unselectResource')}</p>} - > - <EuiButtonIcon - iconType="cross" - aria-label={t('maps:selectedPointsList.actions.unselectResource')} - onClick={() => onUncheckButtonClick()} - color={'danger'} size={'s'} /> </EuiToolTip> - <EuiSpacer /> </EuiFlexGroup> </EuiPanel> - ); - }; - - const buildSelectedPointList = () => { - return selectedPoints.map((point, index) => { - return ( - <SelectedPointItem key={index} name={point.nom} id={point.linkedPointId} /> - ); - }); - }; + <EuiToolTip + position="top" + content={<p>{t('maps:selectedPointsList.actions.unselectResource')}</p>} + > + <EuiButtonIcon + iconType="minusInCircle" + aria-label={t('maps:selectedPointsList.actions.unselectResource')} + onClick={() => onUncheckButtonClick()} + color={'danger'} + size={'s'} + /> + </EuiToolTip> + </EuiFlexGroup> + ); + }; + // Display selected points names + const SelectedPointsList = () => { let selectedPointsList; let totalPoints = getPoints(); let selectedPoints = getSelectedPoints(); if (selectedPoints.length === 0) { - selectedPointsList = <p>{t('maps:selectedPointsList.empty')}</p>; + selectedPointsList = ( + <EuiEmptyPrompt + iconType={'pagesSelect'} + title={<h2>{t('maps:selectedPointsList.emptyTitle')}</h2>} + titleSize={'s'} + body={<p>{t('maps:selectedPointsList.empty')}</p>} + color={'plain'} + /> + ); } else { - selectedPointsList = buildSelectedPointList(); + selectedPointsList = ( + <EuiPanel hasShadow={false} hasBorder={true}> + <EuiText textAlign={'center'}> + <EuiTitle size="s"> + <p> + {t('maps:selectedPointsList.title')} ({selectedPoints.length} /{' '} + {totalPoints.length}) + </p> + </EuiTitle> + </EuiText> + <EuiSpacer size={'l'} /> + <EuiFlexItem> + <EuiFlexGrid + columns={1} + gutterSize={'m'} + direction={'column'} + style={styles.selectedPointsList} + > + {selectedPoints.map((point, index) => { + return ( + <SelectedPointItem + key={index} + name={point.nom} + id={point.linkedPointId} + /> + ); + })} + </EuiFlexGrid> + </EuiFlexItem> + </EuiPanel> + ); } - return ( - <EuiPanel hasShadow={false} hasBorder={true}> - <EuiFlexGroup direction={'column'}> - <EuiTitle size="s"> - <p> - {t('maps:selectedPointsList.title')} ({selectedPoints.length} /{' '} - {totalPoints.length}) - </p> - </EuiTitle> - <EuiFlexGroup direction={'column'} style={styles.selectedPointsList}> - {selectedPointsList} - </EuiFlexGroup> - </EuiFlexGroup> - </EuiPanel> - ); + return <EuiFlexGroup direction={'column'}>{selectedPointsList}</EuiFlexGroup>; }; const selectionToolOptions = [ { id: 'selectionToolButton__0', - label: t('maps:layersTable.selectionTool.select'), + label: t('maps:mapTools.tools.selectionTool.select'), }, { id: 'selectionToolButton__1', - label: t('maps:layersTable.selectionTool.unselect'), + label: t('maps:mapTools.tools.selectionTool.unselect'), }, ]; @@ -779,97 +807,142 @@ const SearchMap = ({ setSelectedPointsIds([]); }; - const MapTools = () => { + const MapToolItem = ({ title, children }) => { return ( - <EuiPanel paddingSize="l" hasShadow={false} hasBorder={true}> - <EuiFlexGroup> - <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} - color={'accent'} - disabled={selectedPointsIds.length === 0} - > - {t('maps:layersTable.selectionTool.unselectAll')} - </EuiButton> - </td> - </tr> - </tbody> - </table> - </EuiFlexGroup> + <EuiPanel paddingSize={'l'} hasShadow={false} hasBorder={true}> + <EuiText textAlign={'center'}> + <EuiTitle size={'xs'}> + <p>{title}</p> + </EuiTitle> + </EuiText> + <EuiSpacer size={'m'} /> + <EuiFlexItem>{children}</EuiFlexItem> </EuiPanel> ); }; + const MapLayersHandler = () => { + return ( + <MapToolItem title={t('maps:mapTools.layers.title')}> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:mapTools.layers.openStreetMap')} + checked={mapLayersVisibility[getLayerIndex('osm-layer')]} + onChange={(e) => setLayerDisplay('osm-layer', e.target.checked)} + /> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:mapTools.layers.bingAerial')} + checked={mapLayersVisibility[getLayerIndex('Bing Aerial')]} + onChange={(e) => setLayerDisplay('Bing Aerial', e.target.checked)} + /> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:mapTools.layers.IGN')} + checked={mapLayersVisibility[getLayerIndex('IGN')]} + onChange={(e) => setLayerDisplay('IGN', e.target.checked)} + /> + </MapToolItem> + ); + }; + + const MapFiltersHandler = () => { + return ( + <MapToolItem title={t('maps:mapTools.filters.title')}> + <EuiCheckbox + id={htmlIdGenerator()()} + label={t('maps:mapTools.filters.queryResults')} + checked={mapLayersVisibility[getLayerIndex('queryResults')]} + onChange={(e) => setLayerDisplay('queryResults', e.target.checked)} + /> + <br /> + <EuiComboBox + aria-label={t('maps:mapTools.filters.selectFilterOption')} + placeholder={t('maps:mapTools.filters.selectFilterOption')} + singleSelection={{ asPlainText: true }} + options={filterOptions} + selectedOptions={selectedFilterOptions} + onChange={onFilterSelectChange} + styles={styles.filtersSelect} + /> + </MapToolItem> + ); + }; + + const MapToolsHandler = () => { + return ( + <MapToolItem title={t('maps:mapTools.tools.title')}> + <EuiText> + <EuiTitle size="xxs"> + <h6>{t('maps:mapTools.tools.selectionTool.title')}</h6> + </EuiTitle> + </EuiText> + <EuiButtonGroup + legend={t('maps:mapTools.tools.selectionTool.title')} + options={selectionToolOptions} + onChange={toggleSelectionToolMode} + idSelected={ + selectionToolMode ? 'selectionToolButton__1' : 'selectionToolButton__0' + } + color={'primary'} + isFullWidth + /> + <EuiSpacer size="s" /> + <EuiButton + onClick={() => unselectPoints()} + style={styles.unselectAllButton} + color={'danger'} + disabled={selectedPointsIds.length === 0} + > + {t('maps:mapTools.tools.selectionTool.unselectAll')} + </EuiButton> + </MapToolItem> + ); + }; + + const MapTools = () => { + return ( + <EuiFlexGrid columns={3}> + <MapLayersHandler /> + <MapFiltersHandler /> + <MapToolsHandler /> + </EuiFlexGrid> + ); + }; + return ( <EuiFlexGroup> <EuiFlexGroup direction={'column'} style={styles.container}> + {(!searchResults || searchResults.length === 0) && ( + <EuiFlexGroup alignItems={'center'}> + <EuiFlexItem> + <EuiCallOut + title={t('maps:noResults')} + color="warning" + iconType="warning" + /> + </EuiFlexItem> + <EuiButton + onClick={() => { + setSelectedTabNumber(0); + }} + color="primary" + fill + key={0} + iconType={'pencil'} + > + {t('results:noResult.action')} + </EuiButton> + </EuiFlexGroup> + )} <EuiFlexItem> - <div id="map" style={styles.mapContainer}></div> + <EuiPanel + id="map" + style={styles.mapContainer} + paddingSize={'none'} + hasBorder + hasShadow={false} + /> {isLoading && <EuiProgress size="l" color="accent" />} </EuiFlexItem> <MapTools /> diff --git a/src/pages/maps/styles.js b/src/pages/maps/styles.js index edf2829c2238055e59ec7661723c04e08836f470..ae61fb15b8857d0516fb366a856367597e2ab5c8 100644 --- a/src/pages/maps/styles.js +++ b/src/pages/maps/styles.js @@ -32,14 +32,7 @@ const styles = { selectedPointsList: { overflow: 'auto', maxHeight: '72vh', - }, - layersTable: { - width: '70vw', - cursor: 'pointer', - marginTop: '10px', - }, - layersTableCells: { - padding: '10px', + paddingRight: '15px', }, filtersSelect: { maxWidth: '50%', diff --git a/src/pages/profile/GroupSettings.js b/src/pages/profile/GroupSettings.js index 1eeb85bcb0ad9bc56ccd770bd0098fd504fafc04..90e2fe9e06cc274796587de436ed0e127ecd94f1 100644 --- a/src/pages/profile/GroupSettings.js +++ b/src/pages/profile/GroupSettings.js @@ -10,56 +10,50 @@ import { EuiFlexGroup, EuiSpacer, } from '@elastic/eui'; -import { getGroups, sendMail, createUserRequest } from '../../actions/user'; import { useTranslation } from 'react-i18next'; -import styles from './styles'; import { toast } from 'react-toastify'; import ToastMessage from '../../components/ToastMessage/ToastMessage'; +import { useGatekeeper } from '../../contexts/GatekeeperContext'; +import { useUserInfo } from '../../contexts/TokenContext'; const GroupSettings = ({ userGroups }) => { + const getUserInfo = useUserInfo(); + const client = useGatekeeper(); const { t } = useTranslation(['profile', 'common', 'validation']); const [groups, setGroups] = useState([]); const [selectedUserGroups, setSelectedUserGroups] = useState([]); const [valueError, setValueError] = useState(undefined); useEffect(() => { - setSelectedUserGroups(userGroups); + const getUserGroups = async () => { + if (!client) { + return; + } + const groupResults = await client.getGroups(); + const mappedGroups = groupResults.map((group) => { + return { + id: group.id, + label: group.name, + description: group.description, + member: userGroups.some((userGroup) => userGroup.id === group.id) ? 'X' : '', + }; + }); + setGroups(mappedGroups); + }; getUserGroups(); }, []); const groupColumns = [ { field: 'label', name: t('profile:groups.groupName'), width: '30%' }, { field: 'description', name: t('profile:groups.groupDescription') }, + { + field: 'member', + name: t('profile:groups.groupMember'), + width: '10%', + align: 'center', + }, ]; - const getUserGroups = () => { - getGroups().then((groupsResult) => { - const groupsArray = []; - groupsResult.forEach((group) => { - groupsArray.push({ - id: group.id, - label: group.name, - description: group.description, - }); - }); - setGroups(groupsArray); - }); - }; - - const getUserGroupLabels = (groups) => { - let labelList = ''; - if (!groups || groups.length === 0) { - return labelList; - } - groups.forEach((group) => { - labelList = `${labelList} ${group.label},`; - }); - if (labelList.endsWith(',')) { - labelList = labelList.substring(0, labelList.length - 1); - } - return labelList; - }; - const onValueSearchChange = (value, hasMatchingOptions) => { if (value.length !== 0 && !hasMatchingOptions) { setValueError(t('profile:groupRequests.invalidOption')); @@ -67,13 +61,14 @@ const GroupSettings = ({ userGroups }) => { }; const onSendGroupRequest = async () => { + const userInfo = await getUserInfo(); if (!selectedUserGroups || selectedUserGroups.length === 0) { return; } - const groupList = getUserGroupLabels(selectedUserGroups); - const message = `The user ${sessionStorage.getItem('username')} (${sessionStorage.getItem('email')}) has made a request to be part of these groups : ${groupList}.`; - const result = await createUserRequest(sessionStorage.getItem('kcId'), message); - await sendMail('User group request', message); + const message = `The user ${userInfo.email} has made a request to be part of these groups : ${selectedUserGroups + .map((group) => group.label) + .join(', ')}.`; + const result = await client.createUserRequest(message, userInfo.sub); if (result.error) { toast.error(<ToastMessage title={t('validation:error')} message={result.error} />); } else { @@ -82,61 +77,56 @@ const GroupSettings = ({ userGroups }) => { }; return ( - <EuiFlexGroup> - <EuiFlexItem> - <EuiPanel paddingSize="l" hasShadow={false} hasBorder={true}> - <EuiTitle size="s"> - <h3>{t('profile:groups.groupsList')}</h3> - </EuiTitle> - <EuiSpacer size={'l'} /> - <EuiBasicTable items={groups} columns={groupColumns} /> - </EuiPanel> - </EuiFlexItem> + userGroups && + userGroups.length > 0 && ( + <EuiFlexGroup> + <EuiFlexItem> + <EuiPanel paddingSize="l" hasShadow={false} hasBorder={true}> + <EuiTitle size="s"> + <h3>{t('profile:groups.groupsList')}</h3> + </EuiTitle> + <EuiSpacer size={'l'} /> + <EuiBasicTable items={groups} columns={groupColumns} /> + </EuiPanel> + </EuiFlexItem> - <EuiFlexItem> - <EuiPanel paddingSize="l" hasShadow={false} hasBorder={true}> - <EuiTitle size="s"> - <h3>{t('profile:groupRequests.requestGroupAssignment')}</h3> - </EuiTitle> - <EuiSpacer size={'l'} /> - {userGroups ? ( - <p - style={styles.currentRoleOrGroupText} - >{`${t('profile:groupRequests.currentGroups')} ${getUserGroupLabels(userGroups)}`}</p> - ) : ( - <p>{t('profile:groupRequests.noGroup')}</p> - )} - <EuiSpacer size={'l'} /> - <EuiFormRow error={valueError} isInvalid={valueError !== undefined}> - <EuiComboBox - placeholder={t('profile:groupRequests.selectGroup')} - options={groups} - selectedOptions={selectedUserGroups} - onChange={(selectedOptions) => { - setValueError(undefined); - setSelectedUserGroups(selectedOptions); - }} - onSearchChange={onValueSearchChange} - /> - </EuiFormRow> - <EuiSpacer size={'l'} /> - - <EuiFlexItem> - <div> - <EuiButton - disabled={selectedUserGroups.length === 0} - onClick={() => { - onSendGroupRequest(); + <EuiFlexItem> + <EuiPanel paddingSize="l" hasShadow={false} hasBorder={true}> + <EuiTitle size="s"> + <h3>{t('profile:groupRequests.requestGroupAssignment')}</h3> + </EuiTitle> + <EuiSpacer size={'l'} /> + <EuiFormRow error={valueError} isInvalid={valueError !== undefined}> + <EuiComboBox + placeholder={t('profile:groupRequests.selectGroup')} + options={groups.filter((group) => group.member != 'X')} + selectedOptions={selectedUserGroups} + onChange={(selectedOptions) => { + setValueError(undefined); + setSelectedUserGroups(selectedOptions); }} - fill - > - {t('common:validationActions.send')} - </EuiButton> - </div> - </EuiFlexItem> - </EuiPanel> - </EuiFlexItem> - </EuiFlexGroup> + onSearchChange={onValueSearchChange} + /> + </EuiFormRow> + <EuiSpacer size={'l'} /> + + <EuiFlexItem> + <div> + <EuiButton + disabled={selectedUserGroups.length === 0} + onClick={() => { + onSendGroupRequest(); + }} + fill + > + {t('common:validationActions.send')} + </EuiButton> + </div> + </EuiFlexItem> + </EuiPanel> + </EuiFlexItem> + </EuiFlexGroup> + ) ); }; diff --git a/src/pages/profile/MyProfile.js b/src/pages/profile/MyProfile.js index 57bf89a0ed383b4764682ab1a4a3e1d65d1a0b98..67bffe375d2ac3dc2f230f97d1fef12c41584c50 100644 --- a/src/pages/profile/MyProfile.js +++ b/src/pages/profile/MyProfile.js @@ -5,16 +5,18 @@ import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, + EuiIconTip, EuiPanel, EuiSpacer, + EuiText, EuiTitle, - EuiIconTip, } from '@elastic/eui'; -import { deleteUserRequest } from '../../actions/user'; +import { useGatekeeper } from '../../contexts/GatekeeperContext'; import { buildFieldName } from '../../Utils'; import BulletPointList from '../../components/BulletPointList/BulletPointList'; import { toast } from 'react-toastify'; import ToastMessage from '../../components/ToastMessage/ToastMessage'; +import { useAuth } from 'oidc-react'; const MyProfile = ({ setSelectedTabNumber, @@ -25,6 +27,8 @@ const MyProfile = ({ publicFields, getUserRequests, }) => { + const client = useGatekeeper(); + const auth = useAuth(); const { t } = useTranslation(['profile']); const MyProfileCustomPanel = ({ @@ -70,9 +74,9 @@ const MyProfile = ({ }; const onDeleteRequest = async (request) => { - const result = await deleteUserRequest(request.id); - if (result.error) { - toast.error(<ToastMessage title={t('validation:error')} message={result.error} />); + const { success, message } = await client.deleteUserRequest(request.id); + if (!success) { + toast.error(<ToastMessage title={t('validation:error')} message={message} />); } else { toast.success(t('profile:requestsList.requestCanceled')); // Refresh requests table @@ -114,17 +118,27 @@ const MyProfile = ({ const GroupList = () => { if (!userGroups || userGroups.length === 0) { - return <p>{t('profile:groupRequests.noGroup')}</p>; + return ( + <EuiText> + <p>{t('profile:groupRequests.noGroup')}</p> + </EuiText> + ); } - const listItems = userGroups.map((group, index) => ( - <li key={index}>{group.label}</li> - )); - return <BulletPointList>{listItems}</BulletPointList>; + const listItems = userGroups.map((group, index) => <li key={index}>{group.name}</li>); + return ( + <EuiText> + <BulletPointList>{listItems}</BulletPointList> + </EuiText> + ); }; const FieldsDisplaySettings = () => { if (!fieldsDisplaySettingsIds || fieldsDisplaySettingsIds.length === 0) { - return <p>{t('profile:fieldsDisplaySettings.noSettings')}</p>; + return ( + <EuiText> + <p>{t('profile:fieldsDisplaySettings.noSettings')}</p> + </EuiText> + ); } const fieldsDisplaySettings = []; publicFields.forEach((field) => { @@ -136,13 +150,17 @@ const MyProfile = ({ <li key={index}>{fieldName}</li> )); // Have to style list manually because of display flex container - return <BulletPointList>{listItems}</BulletPointList>; + return ( + <EuiText> + <BulletPointList>{listItems}</BulletPointList> + </EuiText> + ); }; return ( <> <EuiTitle> - <h3>{sessionStorage.getItem('username')}</h3> + <h3>{auth.userData?.profile?.preferred_username || ''}</h3> </EuiTitle> <EuiFlexGroup> <MyProfileCustomPanel @@ -159,7 +177,9 @@ const MyProfile = ({ linkedTabButton={t('profile:myProfile.rolePanel.edit')} linkedTabNumber={2} > - <p>{userRole}</p> + <EuiText> + <p>{userRole}</p> + </EuiText> </MyProfileCustomPanel> </EuiFlexGroup> @@ -171,7 +191,9 @@ const MyProfile = ({ <EuiBasicTable items={userRequests} columns={requestsColumns} /> ) : ( <EuiFlexGroup justifyContent={'center'}> - <p>{t('profile:requestsList.noCurrentRequest')}</p> + <EuiText> + <p>{t('profile:requestsList.noCurrentRequest')}</p> + </EuiText> </EuiFlexGroup> )} </MyProfileCustomPanel> @@ -190,7 +212,9 @@ const MyProfile = ({ linkedTabButton={t('profile:myProfile.fieldsDownloadSettingsPanel.edit')} linkedTabNumber={3} > - <p>Fonctionnalité à venir prochainement.</p> + <EuiText> + <p>Fonctionnalité à venir prochainement.</p> + </EuiText> </MyProfileCustomPanel> </EuiFlexGroup> </> diff --git a/src/pages/profile/Profile.js b/src/pages/profile/Profile.js index 81c0d26781230fa0e930dee0143a956a9c7608f5..111b6c37b5cb3bd6a9f3e1a39ace881e06cabeca 100644 --- a/src/pages/profile/Profile.js +++ b/src/pages/profile/Profile.js @@ -5,14 +5,12 @@ import UserFieldsDisplaySettings from './UserFieldsDisplaySettings'; import GroupSettings from './GroupSettings'; import RoleSettings from './RoleSettings'; import MyProfile from './MyProfile'; -import { - fetchUserFieldsDisplaySettings, - fetchUserRequests, - findOneUserWithGroupAndRole, -} from '../../actions/user'; -import { fetchPublicFields } from '../../actions/source'; +import { useGatekeeper } from '../../contexts/GatekeeperContext'; +import { useUserInfo } from '../../contexts/TokenContext'; const Profile = () => { + const getUserInfo = useUserInfo(); + const client = useGatekeeper(); const { t } = useTranslation(['profile', 'common', 'validation']); const [selectedTabNumber, setSelectedTabNumber] = useState(0); const [userGroups, setUserGroups] = useState([]); @@ -21,56 +19,38 @@ const Profile = () => { const [fieldsDisplaySettings, setFieldsDisplaySettings] = useState(null); const [publicFields, setPublicFields] = useState([]); - useEffect(() => { - findOneUserWithGroupAndRole(sessionStorage.getItem('kcId')).then((result) => { - if (result) { - if (result[0]) { - setUserRole(result[0].rolename); - } - const userGroupList = []; - result.forEach((userGroup) => { - if (userGroup.groupname) { - userGroupList.push({ - id: userGroup.groupid, - label: userGroup.groupname, - description: userGroup.groupdescription, - }); - } - }); - setUserGroups(userGroupList); - } - }); - getUserRequests(); - getUserFieldsDisplaySettings(); - getPublicFields(); - }, []); - - const getUserRequests = () => { - fetchUserRequests(sessionStorage.getItem('kcId')).then((userRequests) => { - if (userRequests) { - setUserRequests([...userRequests]); - } - }); - }; - - const getUserFieldsDisplaySettings = () => { - fetchUserFieldsDisplaySettings(sessionStorage.getItem('userId')).then( - (userSettings) => { - if (userSettings) { - setFieldsDisplaySettings(userSettings); - } - } + const fetchUser = async () => { + const { sub } = await getUserInfo(); + const user = await client.findUserBySub(sub); + if (!user.id) { + return; + } + const { groups, roles, requests, display_fields } = user; + setUserGroups( + groups.map((group) => { + return { + id: group.group.id, + name: group.group.name, + }; + }) ); + setUserRole(roles[0]?.role?.name); + setUserRequests(requests); + setFieldsDisplaySettings(display_fields.map((field) => field.std_field_id)); }; - const getPublicFields = () => { - fetchPublicFields().then((publicFieldsResults) => { - if (publicFieldsResults) { - setPublicFields(publicFieldsResults); - } - }); + const fetchFields = async () => { + const fields = await client.getPublicFields(); + setPublicFields(fields); }; + useEffect(() => { + if (client && selectedTabNumber === 0) { + fetchUser(); + fetchFields(); + } + }, [selectedTabNumber]); + const Tab = ({ children, description }) => { const [showCallOut, setShowCallOut] = useState(true); @@ -113,7 +93,7 @@ const Profile = () => { userRequests={userRequests} fieldsDisplaySettingsIds={fieldsDisplaySettings} publicFields={publicFields} - getUserRequests={() => getUserRequests()} + getUserRequests={() => fetchUser()} /> </Tab> ), diff --git a/src/pages/profile/RoleSettings.js b/src/pages/profile/RoleSettings.js index dc91ad226cf26c7556e27f8f798cfd28c33da83d..885b41547c65356a6834f49a0c5f225f0da83329 100644 --- a/src/pages/profile/RoleSettings.js +++ b/src/pages/profile/RoleSettings.js @@ -1,44 +1,45 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { - EuiTitle, - EuiSelect, EuiButton, - EuiFormRow, + EuiFlexGroup, EuiFlexItem, + EuiFormRow, EuiPanel, + EuiSelect, EuiSpacer, - EuiFlexGroup, + EuiText, + EuiTitle, } from '@elastic/eui'; -import { getRoles, sendMail, createUserRequest } from '../../actions/user'; import { useTranslation } from 'react-i18next'; import styles from './styles'; import { toast } from 'react-toastify'; import ToastMessage from '../../components/ToastMessage/ToastMessage'; +import { useGatekeeper } from '../../contexts/GatekeeperContext'; +import { useUserInfo } from '../../contexts/TokenContext'; const RoleSettings = ({ userRole }) => { + const client = useGatekeeper(); + const getUserInfo = useUserInfo(); const { t } = useTranslation(['profile', 'common', 'validation']); const [roles, setRoles] = useState([]); const [selectedRole, setSelectedRole] = useState(undefined); useEffect(() => { - getUserRoles(); - }, []); - - const getUserRoles = () => { - getRoles().then((rolesResult) => { - const rolesArray = []; - rolesResult.forEach((role) => { - rolesArray.push({ id: role.id, text: role.name }); - }); - setRoles(rolesArray); - }); - }; + const fetchRoles = async () => { + const roles = await client.getRoles(); + const filteredRoles = roles + .filter((role) => role.name !== userRole) + .map((role) => ({ value: role.name, text: role.name })); + setRoles(filteredRoles); + }; + fetchRoles(); + }, [client]); const onSendRoleRequest = async () => { + const { email, sub } = await getUserInfo(); if (selectedRole) { - const message = `The user ${sessionStorage.getItem('username')} (${sessionStorage.getItem('email')}) has made a request to get the role : ${selectedRole}.`; - const result = await createUserRequest(sessionStorage.getItem('kcId'), message); - await sendMail('User role request', message); + const message = `The user ${email} has made a request to get the role : ${selectedRole}.`; + const result = await client.createUserRequest(message, sub); if (result.error) { toast.error( <ToastMessage title={t('validation:error')} message={result.error} /> @@ -58,9 +59,11 @@ const RoleSettings = ({ userRole }) => { </EuiTitle> <EuiSpacer size={'l'} /> {userRole && ( - <p style={styles.currentRoleOrGroupText}> - {`${t('profile:roleRequests.currentRole')} ${userRole}`} - </p> + <EuiText> + <p style={styles.currentRoleOrGroupText}> + {`${t('profile:roleRequests.currentRole')} ${userRole}`} + </p> + </EuiText> )} <EuiSpacer size={'l'} /> <EuiFormRow> @@ -74,7 +77,7 @@ const RoleSettings = ({ userRole }) => { /> </EuiFormRow> <EuiSpacer size={'l'} /> - <EuiButton onClick={() => onSendRoleRequest()} fill> + <EuiButton onClick={() => onSendRoleRequest()} fill disabled={!selectedRole}> {t('common:validationActions.send')} </EuiButton> </EuiPanel> diff --git a/src/pages/profile/UserFieldsDisplaySettings.js b/src/pages/profile/UserFieldsDisplaySettings.js index 2984427619b8b2f00ea6c6664cfa872543663f32..8859a3e1cc7b660984795a25e370872a5e1c0e94 100644 --- a/src/pages/profile/UserFieldsDisplaySettings.js +++ b/src/pages/profile/UserFieldsDisplaySettings.js @@ -1,29 +1,29 @@ import React, { useEffect, useState } from 'react'; import { - EuiSpacer, - EuiSelectable, EuiButton, - EuiFlexItem, EuiFlexGroup, + EuiFlexItem, EuiPanel, + EuiSelectable, + EuiSpacer, + EuiText, EuiTitle, } from '@elastic/eui'; -import { - createUserFieldsDisplaySettings, - deleteUserFieldsDisplaySettings, - updateUserFieldsDisplaySettings, -} from '../../actions/user'; import { Trans, useTranslation } from 'react-i18next'; import { buildFieldName } from '../../Utils'; import BulletPointList from '../../components/BulletPointList/BulletPointList'; import { toast } from 'react-toastify'; import ToastMessage from '../../components/ToastMessage/ToastMessage'; +import { useGatekeeper } from '../../contexts/GatekeeperContext'; +import { useUserInfo } from '../../contexts/TokenContext'; /* User fields display settings are used to choose which fields are displayed in results table after a search. If the user has no settings, the default are used. Default settings are the same for all users, chosen by admin at standard setup. */ const UserFieldsDisplaySettings = ({ userSettings, setUserSettings, publicFields }) => { + const client = useGatekeeper(); + const getUserInfo = useUserInfo(); const { t } = useTranslation(['profile', 'common', 'validation']); const [settingsOptions, setSettingsOptions] = useState([]); const [selectedOptionsIds, setSelectedOptionsIds] = useState([]); @@ -87,22 +87,14 @@ const UserFieldsDisplaySettings = ({ userSettings, setUserSettings, publicFields if (!selectedOptionsIds || selectedOptionsIds.length === 0) { return; } - let result; - if (userSettings) { - result = await updateUserFieldsDisplaySettings( - sessionStorage.getItem('userId'), - selectedOptionsIds + const { sub } = await getUserInfo(); + const response = await client.setUserFieldsDisplaySettings(sub, selectedOptionsIds); + if (response && response.length === 0) { + toast.error( + <ToastMessage title={t('validation:error')} message={response.error} /> ); } else { - result = await createUserFieldsDisplaySettings( - sessionStorage.getItem('userId'), - selectedOptionsIds - ); - } - setUserSettings(selectedOptionsIds); - if (result.error) { - toast.error(<ToastMessage title={t('validation:error')} message={result.error} />); - } else { + setUserSettings(selectedOptionsIds); toast.success(t('profile:fieldsDisplaySettings.updatedSettingsSuccess')); } }; @@ -126,45 +118,38 @@ const UserFieldsDisplaySettings = ({ userSettings, setUserSettings, publicFields // With current logic, no user settings means it should use default. // So in this case 'reset' means delete current user settings. const onSettingsReset = async () => { - // TODO add a confirmation modal ? - const result = await deleteUserFieldsDisplaySettings( - sessionStorage.getItem('userId') - ); - if (result.error) { - toast.error(<ToastMessage title={t('validation:error')} message={result.error} />); - } else { - setUserSettings(null); - toast.success( - <ToastMessage - title={t('profile:fieldsDisplaySettings.deleteSettingsSuccess')} - message={t('profile:fieldsDisplaySettings.deleteSettingsSuccessDefault')} - /> - ); + if (!settingsOptions) { + toast.error(t('profile:fieldsDisplaySettings.selectionResetFailure')); + return; } + const newSettingsOptions = []; + settingsOptions.forEach((option) => { + option.checked = false; + newSettingsOptions.push(option); + }); + setSettingsOptions(newSettingsOptions); }; const SelectableSettingsPanel = () => { return ( <EuiFlexItem> - <EuiPanel paddingSize="l" hasShadow={false} hasBorder={true}> - <EuiSelectable - aria-label={t('profile:fieldsDisplaySettings.selectedOptionsLabel')} - options={settingsOptions} - onChange={(newOptions) => setSettingsOptions(newOptions)} - searchable - listProps={{ bordered: true }} - style={{ minHeight: '65vh' }} - height={'full'} - > - {(list, search) => ( - <> - {search} - <EuiSpacer size={'xs'} /> - {list} - </> - )} - </EuiSelectable> - </EuiPanel> + <EuiSelectable + aria-label={t('profile:fieldsDisplaySettings.selectedOptionsLabel')} + options={settingsOptions} + onChange={(newOptions) => setSettingsOptions(newOptions)} + searchable + listProps={{ bordered: true }} + style={{ minHeight: '65vh' }} + height={'full'} + > + {(list, search) => ( + <> + {search} + <EuiSpacer size={'xs'} /> + {list} + </> + )} + </EuiSelectable> </EuiFlexItem> ); }; @@ -172,11 +157,13 @@ const UserFieldsDisplaySettings = ({ userSettings, setUserSettings, publicFields const SelectedSettingsPanel = () => { const SelectedSettingsList = () => { return ( - <BulletPointList> - {selectedOptions.map((option, index) => { - return <li key={index}>{option}</li>; - })} - </BulletPointList> + <EuiText> + <BulletPointList> + {selectedOptions.map((option, index) => { + return <li key={index}>{option}</li>; + })} + </BulletPointList> + </EuiText> ); }; diff --git a/src/pages/results/ResourceFlyout/JSON2MD.js b/src/pages/results/ResourceFlyout/JSON2MD.js index 1c25fdb2b04a3e3827b8e56d8b9efb4cadb6581a..00c85ff1511989fae3f65f85498debf6f90bc1cd 100644 --- a/src/pages/results/ResourceFlyout/JSON2MD.js +++ b/src/pages/results/ResourceFlyout/JSON2MD.js @@ -2,16 +2,7 @@ import standard_markdown_styles from './standard_markdown_styles_FR_EN.json'; const DEFAULT_ARRAY_SEPARATOR = ','; -const handleString = ( - key, - value, - styles, - lang, - indent = '', - level, - labelType, - rejectNull -) => { +const handleString = (key, value, styles, lang, level, labelType, rejectNull) => { const indentLocal = ' '.repeat((level - 1) * 2); const style = styles[key] ? styles[key][lang] @@ -29,16 +20,7 @@ const handleString = ( } }; -const handleObject = ( - key, - value, - styles, - lang, - indent = '', - level, - labelType, - rejectNull -) => { +const handleObject = (key, value, styles, lang, level, labelType, rejectNull) => { const style = styles[key] ? styles[key][lang] : { value_style: '**', title_style: '', label: key, definition: '' }; @@ -224,7 +206,7 @@ const processKeyValue = ( case 'bigint': break; case 'string': - return handleString(key, value, styles, lang, indent, level, labelType, rejectNull); + return handleString(key, value, styles, lang, level, labelType, rejectNull); case 'object': if (Array.isArray(value)) { if (value.length > 0 && typeof value[0] === 'object') { @@ -251,16 +233,7 @@ const processKeyValue = ( ); } } else if (value !== null) { - return handleObject( - key, - value, - styles, - lang, - indent, - level, - labelType, - rejectNull - ); + return handleObject(key, value, styles, lang, level, labelType, rejectNull); } else if ((typeof value !== 'undefined' && false && value !== '') || !rejectNull) { return `\n${indentLocal}- ${localLabel} = `; } else { diff --git a/src/pages/results/ResourceFlyout/ResourceJSON.js b/src/pages/results/ResourceFlyout/ResourceJSON.js index bd12c0bae9ea2b66ddf046f0b17eee2a947cc717..a6e7b4ad6b3c00730ca2b80eea1ecc159111070f 100644 --- a/src/pages/results/ResourceFlyout/ResourceJSON.js +++ b/src/pages/results/ResourceFlyout/ResourceJSON.js @@ -16,7 +16,7 @@ const ResourceJSON = ({ resourceData }) => { displayArrayKey={false} displayDataTypes={false} displayObjectSize={false} - collapsed={false} + collapsed={true} /> </EuiText> ); diff --git a/src/pages/results/ResourceFlyout/ResourceMarkdown.js b/src/pages/results/ResourceFlyout/ResourceMarkdown.js index b7e2fb9670adfa47438fbeb4b08113c86ec6256f..4a8558d93d361f79f8653c521fb9ca4764867c54 100644 --- a/src/pages/results/ResourceFlyout/ResourceMarkdown.js +++ b/src/pages/results/ResourceFlyout/ResourceMarkdown.js @@ -1,19 +1,34 @@ -import React from 'react'; -import { EuiText } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui'; import { JSON2MD } from './JSON2MD'; import { useTranslation } from 'react-i18next'; import Markdown from 'react-markdown'; const ResourceMarkdown = ({ resourceData }) => { - const { i18n } = useTranslation(); + const { t, i18n } = useTranslation('results'); + const [isLoading, setIsLoading] = useState(true); + const [markdownString, setMarkdownString] = useState(''); - const buildMarkdown = () => { - return JSON2MD(resourceData, i18n.language, 'HT', true); - }; + useEffect(() => { + const buildMarkdown = () => { + return JSON2MD(resourceData, i18n.language, 'HT', true); + }; + setMarkdownString(buildMarkdown()); + }, []); - const markdownString = buildMarkdown(); + useEffect(() => { + if (markdownString !== '') { + setIsLoading(false); + } + }, [markdownString]); - return ( + return isLoading ? ( + <EuiEmptyPrompt + title={<h2>{t('results:flyout.loading')}</h2>} + icon={<EuiLoadingSpinner size="xxl" />} + titleSize={'s'} + /> + ) : ( <EuiText size="s"> <Markdown>{markdownString}</Markdown> </EuiText> diff --git a/src/pages/results/Results.js b/src/pages/results/Results.js index 5ebb6f4023a11f59e78d17148276f5be8dc5935a..5171e2160f1a6a0d7b98293e6384ef27815373fc 100644 --- a/src/pages/results/Results.js +++ b/src/pages/results/Results.js @@ -1,8 +1,9 @@ import React from 'react'; -import { EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useTranslation } from 'react-i18next'; import ResultsTableMUI from './ResultsTableMUI'; import ResultsDownload from './ResultsDownload'; +import NoResultEmptyPrompt from '../../components/NoResultEmptyPrompt/NoResultEmptyPrompt'; const Results = ({ searchResults, @@ -11,9 +12,14 @@ const Results = ({ setSelectedRowsIds, setResourceFlyoutDataFromId, setIsResourceFlyoutOpen, + setSelectedTabNumber, }) => { const { t } = useTranslation('results'); + if (!searchResults || searchResults.length === 0) { + return <NoResultEmptyPrompt setSelectedTabNumber={setSelectedTabNumber} />; + } + return ( <> <EuiFlexGroup> @@ -28,7 +34,6 @@ const Results = ({ /> </EuiFlexItem> </EuiFlexGroup> - <EuiSpacer size={'m'} /> <ResultsTableMUI searchResults={searchResults} searchQuery={searchQuery} diff --git a/src/pages/results/ResultsTableMUI.js b/src/pages/results/ResultsTableMUI.js index 1a7b22b8d70ea9331a31eaa71a8c32210272873d..fcba67efefdc40c23c93ee75ebe95076c954c6c5 100644 --- a/src/pages/results/ResultsTableMUI.js +++ b/src/pages/results/ResultsTableMUI.js @@ -1,25 +1,11 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import MUIDataTable from 'mui-datatables'; import { createTheme, ThemeProvider } from '@mui/material'; -import { fetchPublicFields } from '../../actions/source'; -import { fetchUserFieldsDisplaySettings } from '../../actions/user'; import { buildFieldName } from '../../Utils'; - -const getMuiTheme = () => - createTheme({ - components: { - MuiTableRow: { - styleOverrides: { - root: { - '&:hover': { - cursor: 'pointer', - }, - }, - }, - }, - }, - }); +import { useGatekeeper } from '../../contexts/GatekeeperContext'; +import { useUserInfo } from '../../contexts/TokenContext'; +import { EuiText, EuiTitle, transparentize, useEuiTheme } from '@elastic/eui'; const ResultsTableMUI = ({ searchResults, @@ -30,65 +16,126 @@ const ResultsTableMUI = ({ setResourceFlyoutDataFromId, }) => { const { t } = useTranslation('results'); + const client = useGatekeeper(); + const getUserInfo = useUserInfo(); + const { euiTheme } = useEuiTheme(); const [publicFields, setPublicFields] = useState([]); - const [userFieldsIds, setUserFieldsIds] = useState([]); const [isLoading, setIsLoading] = useState(true); const [rowsPerPage, setRowsPerPage] = useState(15); + const [rows, setRows] = useState([]); + const [columns, setColumns] = useState([]); - // Fetch public fields and user display settings useEffect(() => { - const fetchData = async () => { - setIsLoading(true); - const publicFieldsResults = await fetchPublicFields(); - setPublicFields(publicFieldsResults); - const userStdFieldsIds = await fetchUserFieldsDisplaySettings( - sessionStorage.getItem('userId') - ); - const defaultStdFieldsIds = [1, 20, 36, 9, 31, 13]; - // TODO replace hard-coded array by gatekeeper fetch on default settings - // If no userStdFields, use system default ones. - setUserFieldsIds(userStdFieldsIds || defaultStdFieldsIds); + const fetchFields = async () => { + const publicFields = await client.getPublicFields(); + setPublicFields(publicFields); }; - fetchData(); + if (!searchResults || searchResults.length === 0 || searchResults.error) { + return; + } + fetchFields(); }, [searchResults]); - // Memoize columns - const columns = useMemo(() => { - if (publicFields.length === 0) { - return []; + useEffect(() => { + const getUserFieldsDisplaySettings = async () => { + // TODO: get also private fields that user has access to + const userInfo = await getUserInfo(); + const userFields = await client.getUserFieldsDisplaySettings(userInfo.sub); + const defaultFields = await client.getDefaultFieldsDisplaySettings(); + if (userFields && userFields.length > 0) { + const newColumns = await buildColumns(userFields); + setColumns(newColumns); + const newRows = buildRows(searchResults, newColumns); + setRows(newRows); + setIsLoading(false); + } else { + const newColumns = await buildColumns(defaultFields); + setColumns(newColumns); + const newRows = buildRows(searchResults, newColumns); + setRows(newRows); + setIsLoading(false); + } + }; + if (!searchResults || searchResults.length === 0 || searchResults.error) { + return; } - let dataColumns = [ + getUserFieldsDisplaySettings(); + }, [publicFields]); + + const createFieldColumn = (fieldName, isDisplayed) => { + return { + name: fieldName, + label: buildFieldName(fieldName), + options: { + display: isDisplayed, + // Apply styling on columns headers + customHeadLabelRender: (columnMeta) => { + // Apply styling on columns headers text + return ( + <EuiTitle size={'xxs'}> + <p>{columnMeta.label}</p> + </EuiTitle> + ); + }, + customBodyRender: (value) => { + return ( + <span + style={{ + display: 'inline-block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '100%', + }} + > + {value} + </span> + ); + }, + setCellProps: () => ({ + style: { + maxWidth: '250px', + }, + }), + }, + }; + }; + + const buildColumns = async (userFields) => { + let columns = [ { name: 'id', label: 'ID', options: { display: 'excluded' }, }, ]; - publicFields.forEach((publicField) => { - dataColumns.push({ - name: publicField.field_name, - label: buildFieldName(publicField.field_name), - options: { - display: userFieldsIds.includes(publicField.id), - customBodyRenderLite: (dataIndex, rowIndex) => { - let value = rows[rowIndex][publicField.field_name]; - if (value && value.length >= 150) { - value = value.substring(0, 150) + ' ...'; - } - return value; - }, - setCellProps: () => ({ - style: { - maxWidth: '350px', - }, - }), - }, + publicFields.forEach((field) => { + // Create columns for all public fields with display set to false. + // This way they can be used in table options (checkbox) + columns.push(createFieldColumn(field.field_name, false)); + }); + userFields.forEach((field) => { + // Set columns display to true according to user choices (or default ones). + const index = columns.findIndex( + (columnItem) => columnItem.name === field.field_name + ); + if (index !== -1) { + columns[index].options.display = true; + } + }); + return columns; + }; + + const buildRows = (results, columns) => { + return results.map((result) => { + let row = { id: result.id }; + columns.forEach((column) => { + const value = getValueByPath(result, column.name); + row[column.name] = typeof value === 'string' ? value : value?.toString(); }); + return row; }); - // sort columns alphabetically for clarity - dataColumns.sort((a, b) => (a.name > b.name ? 1 : -1)); - return dataColumns; - }, [publicFields, userFieldsIds]); + }; // Returns value from JSON obj associated to key string. const getValueByPath = (obj, path) => { @@ -102,49 +149,9 @@ const ResultsTableMUI = ({ }, obj); }; - const rows = useMemo(() => { - const buildRows = (results, columns) => { - if (results.length === 0) { - return []; - } - return results.map((result) => { - let row = { id: result.id }; - columns.forEach((column) => { - const value = getValueByPath(result, column.name); - row[column.name] = typeof value === 'string' ? value : value?.toString(); - }); - return row; - }); - }; - const rows = buildRows(searchResults, columns); - setIsLoading(false); - return searchResults && columns.length > 0 ? rows : []; - }, [searchResults, columns]); - - const getRowIdFromResourceData = (id) => { - if (!rows || rows.length === 0) { - return -1; - } - for (let index = 0; index < rows.length; index++) { - if (rows[index].id === id) { - return index; - } - } - return -1; - }; - - // On page load, check table rows from selected resources from map - const selectedRows = useMemo(() => { - return selectedRowsIds.map((id) => getRowIdFromResourceData(id)); - }, [rows, selectedRowsIds]); - // Add row to list of selected on checkbox click - const onRowSelectionCallback = (selectedRow, allSelectedRows) => { - setSelectedRowsIds( - allSelectedRows.map((row) => { - return rows[row.dataIndex].id; - }) - ); + const onRowSelectionCallback = (currentRowsSelected, allRowsSelected, rowsSelected) => { + setSelectedRowsIds(rowsSelected.map((index) => rows[index].id)); }; // Open resource flyout on row click (any cell) @@ -193,6 +200,30 @@ const ResultsTableMUI = ({ }, }; + const getMuiTheme = () => + createTheme({ + components: { + MUIDataTableBodyRow: { + styleOverrides: { + root: { + '&:nth-of-type(odd)': { + backgroundColor: transparentize(euiTheme.colors.lightShade, 0.5), + }, + }, + }, + }, + MuiTableRow: { + styleOverrides: { + root: { + '&:hover': { + cursor: 'pointer', + }, + }, + }, + }, + }, + }); + const tableOptions = { print: false, download: false, @@ -205,7 +236,9 @@ const ResultsTableMUI = ({ }, selectableRows: 'multiple', selectableRowsOnClick: false, - rowsSelected: selectedRows, + rowsSelected: rows + .filter((row) => selectedRowsIds.includes(row.id)) + .map((row) => rows.indexOf(row)), rowsPerPage: rowsPerPage, onChangeRowsPerPage: (newRowsPerPage) => { setRowsPerPage(newRowsPerPage); @@ -213,7 +246,7 @@ const ResultsTableMUI = ({ rowsPerPageOptions: [15, 30, 50, 100, 250], jumpToPage: true, searchPlaceholder: t('results:table.search'), - elevation: 0, // remove the boxShadow style + elevation: 0, customToolbarSelect: () => <CustomSelectToolbar />, selectToolbarPlacement: 'above', onRowSelectionChange: onRowSelectionCallback, @@ -222,12 +255,21 @@ const ResultsTableMUI = ({ }; const isTableReady = !isLoading && columns.length > 0 && rows.length > 0; + const count = searchResults.length; return ( <ThemeProvider theme={getMuiTheme()}> {isTableReady && ( <MUIDataTable - title={<Trans i18nKey={'results:table.title'} components={{ searchQuery }} />} + title={ + <EuiText> + <Trans + i18nKey={'results:table.title'} + components={{ searchQuery }} + count={count} + /> + </EuiText> + } data={rows} columns={columns} options={tableOptions} diff --git a/src/pages/search/AdvancedSearch/AdvancedSearch.js b/src/pages/search/AdvancedSearch/AdvancedSearch.js index 53f5ad4fe2db2bf1753770e92b0778dbe031618a..025873b89095fd65750fecd26783b53c94f128a7 100644 --- a/src/pages/search/AdvancedSearch/AdvancedSearch.js +++ b/src/pages/search/AdvancedSearch/AdvancedSearch.js @@ -3,6 +3,7 @@ import { EuiButtonEmpty, EuiButtonIcon, EuiComboBox, + EuiDatePicker, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -24,15 +25,15 @@ import { EuiSelect, EuiSpacer, EuiSwitch, + EuiText, EuiTextArea, EuiTextColor, EuiTitle, - EuiDatePicker, } from '@elastic/eui'; import React, { Fragment, useEffect, useState } from 'react'; import { + buildDslQuery, changeNameToLabel, - createAdvancedQueriesBySource, getFieldsBySection, getSections, removeArrayElement, @@ -40,15 +41,15 @@ import { updateArrayElement, updateSearchFieldValues, } from '../../../Utils'; -import { getQueryCount, searchQuery } from '../../../actions/source'; import { DateOptions, NumericOptions, Operators } from '../Data'; -import { addUserHistory, fetchUserHistory } from '../../../actions/user'; import { useTranslation } from 'react-i18next'; import styles from './styles.js'; import moment from 'moment'; import SearchModeSwitcher from '../SearchModeSwitcher'; import { toast } from 'react-toastify'; import ToastMessage from '../../../components/ToastMessage/ToastMessage'; +import { useGatekeeper } from '../../../contexts/GatekeeperContext'; +import { useAuth } from 'oidc-react'; const updateSources = ( searchFields, @@ -69,7 +70,7 @@ const updateSources = ( availableSources = updatedSources; } updatedSources = []; - field.sources.forEach((sourceId) => { + field.sources?.forEach((sourceId) => { noPrivateField = false; const source = availableSources.find((src) => src.id === sourceId); if (source && !updatedSources.includes(source)) { @@ -130,18 +131,6 @@ const fieldValuesToString = (field) => { return strValues; }; -const fetchHistory = (setUserHistory) => { - fetchUserHistory(sessionStorage.getItem('kcId')).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) => { @@ -172,12 +161,27 @@ const HistorySelect = ({ setUserHistory, }) => { const { t } = useTranslation('search'); + const auth = useAuth(); + const client = useGatekeeper(); const [historySelectError, setHistorySelectError] = useState(undefined); const [selectedSavedSearch, setSelectedSavedSearch] = useState(undefined); useEffect(() => { - fetchHistory(setUserHistory); - }, [setUserHistory]); + const fetchHistory = async (sub) => { + const userHistory = await client.getUserHistory(sub); + if (userHistory[0] && userHistory[0].ui_structure) { + userHistory.forEach((item) => { + item.ui_structure = JSON.parse(item.ui_structure); + item.label = `${item.name} - ${new Date(item.createdat).toLocaleString()}`; + }); + setUserHistory(userHistory); + } + }; + const sub = auth.userData?.profile?.sub; + if (sub) { + fetchHistory(sub); + } + }, [auth.userData]); const onHistoryChange = (selectedSavedSearch) => { setHistorySelectError(undefined); @@ -194,13 +198,13 @@ const HistorySelect = ({ setSearchFields([]); return; } - if (!!selectedSavedSearch[0].query) { + if (selectedSavedSearch[0].query) { setSelectedSavedSearch(selectedSavedSearch); setSearch(selectedSavedSearch[0].query); setSearchCount(); setFieldCount([]); } - if (!!selectedSavedSearch[0].ui_structure) { + if (selectedSavedSearch[0].ui_structure) { updateSources( selectedSavedSearch[0].ui_structure, sources, @@ -246,7 +250,6 @@ const SearchBar = ({ setSearchResults, searchFields, setSearchFields, - selectedSources, setSelectedSources, availableSources, setAvailableSources, @@ -262,69 +265,82 @@ const SearchBar = ({ const [userHistory, setUserHistory] = useState({}); const [isSaveSearchModalOpen, setIsSaveSearchModalOpen] = useState(false); const [readOnlyQuery, setReadOnlyQuery] = useState(true); + const auth = useAuth(); + const client = useGatekeeper(); const closeSaveSearchModal = () => { setIsSaveSearchModalOpen(false); }; - const onClickAdvancedSearch = () => { + const onClickAdvancedSearch = async () => { 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 advancedQuery = buildDslQuery(search, standardFields); + const params = { + query: advancedQuery, + sourcesId: availableSources.map((source) => source.id), + fieldsId: standardFields, + advancedQuery: true, + }; + const result = await client.searchQuery(params); + 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 onClickCountResults = async () => { + if (search) { + const query = buildDslQuery(search, standardFields); + const params = { + query, + sourcesId: availableSources.map((source) => source.id), + fieldsId: standardFields, + advancedQuery: true, + }; + const result = await client.searchQuery(params); + if (!result || result.error) { + toast.error( + <ToastMessage title={t('validation:error')} message={result.error} /> + ); + setSearchCount(0); + } + setSearchCount(result.length); } }; - const addHistory = ( + const addHistory = async ( search, searchName, searchFields, searchDescription, setUserHistory ) => { - addUserHistory( - sessionStorage.getItem('kcId'), - search, - searchName, - searchFields, - searchDescription - ).then((result) => { - if (result.error) { - toast.error( - <ToastMessage title={t('validation:error')} message={result.error} /> - ); - } else { - toast.success(t('search:advancedSearch.searchHistory.searchSaved')); + const sub = auth.userData?.profile?.sub; + if (!sub) { + return; + } + const params = { + name: searchName, + query: search, + ui_structure: JSON.stringify(searchFields), + description: searchDescription, + }; + const result = await client.addHistory(sub, params); + if (result.error) { + toast.error(<ToastMessage title={t('validation:error')} message={result.error} />); + } else { + const userHistory = await client.getUserHistory(sub); + if (userHistory[0] && userHistory[0].ui_structure) { + userHistory.forEach((item) => { + item.ui_structure = JSON.parse(item.ui_structure); + item.label = `${item.name} - ${new Date(item.createdat).toLocaleString()}`; + }); + setUserHistory(userHistory); } - fetchHistory(setUserHistory); - }); + } }; const SaveSearchModal = () => { @@ -332,7 +348,7 @@ const SearchBar = ({ const [searchDescription, setSearchDescription] = useState(''); const onClickSaveSearch = () => { - if (!!searchName) { + if (searchName) { addHistory(search, searchName, searchFields, searchDescription, setUserHistory); setSearchName(''); setSearchDescription(''); @@ -403,15 +419,14 @@ const SearchBar = ({ <p>{t('search:advancedSearch.editableQueryToast.title')}</p> </EuiTitle> <EuiSpacer size={'s'} /> - <p>{t('search:advancedSearch.editableQueryToast.content.part1')}</p> - <EuiSpacer size={'s'} /> - <p>{t('search:advancedSearch.editableQueryToast.content.part2')}</p> - <EuiSpacer size={'s'} /> - <ul> - <li>{t('search:advancedSearch.editableQueryToast.content.part3')}</li> - <EuiSpacer size={'s'} /> - <li>{t('search:advancedSearch.editableQueryToast.content.part4')}</li> - </ul> + <EuiText> + <p>{t('search:advancedSearch.editableQueryToast.content.part1')}</p> + <p>{t('search:advancedSearch.editableQueryToast.content.part2')}</p> + <ul> + <li>{t('search:advancedSearch.editableQueryToast.content.part3')}</li> + <li>{t('search:advancedSearch.editableQueryToast.content.part4')}</li> + </ul> + </EuiText> </> ); }; @@ -510,7 +525,7 @@ const PopoverSelect = ({ standardFields, searchFields, setSearchFields }) => { const [selectedSection, setSelectedSection] = useState([]); const handleAddField = () => { - if (!!selectedField[0]) { + if (selectedField[0]) { const field = standardFields.find( (item) => item.field_name.replace(/_|\./g, ' ') === @@ -544,26 +559,9 @@ const PopoverSelect = ({ standardFields, searchFields, setSearchFields }) => { } }; - 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.selectSection')} - singleSelection={{ asPlainText: true }} - options={getFieldsBySection(standardFields, selectedSection[0])} - selectedOptions={selectedField} - onChange={(selected) => setSelectedField(selected)} - isClearable={true} - renderOption={renderOption} - /> - </> - ); - } + const renderOption = (option) => { + const { label, color } = option; + return <EuiHealth color={color}>{label}</EuiHealth>; }; return ( @@ -596,7 +594,18 @@ const PopoverSelect = ({ standardFields, searchFields, setSearchFields }) => { }} isClearable={false} /> - <SelectField /> + <EuiComboBox + placeholder={t('search:advancedSearch.fields.addFieldPopover.selectSection')} + singleSelection={{ asPlainText: true }} + options={getFieldsBySection(standardFields, selectedSection[0])} + selectedOptions={selectedField} + onChange={(selected) => { + setSelectedField(selected); + }} + isClearable={true} + renderOption={renderOption} + isDisabled={selectedSection.length === 0} + /> </EuiFlexGroup> <EuiPopoverFooter style={styles.noBorder} paddingSize={'m'}> <EuiButton @@ -649,9 +658,10 @@ const PopoverValueContent = ({ <p>{t('search:advancedSearch.policyToast.title')}</p> </EuiTitle> <EuiSpacer size={'s'} /> - <p>{t('search:advancedSearch.policyToast.content.0')}</p> - <EuiSpacer size={'s'} /> - <p>{t('search:advancedSearch.policyToast.content.1')}</p> + <EuiText> + <p>{t('search:advancedSearch.policyToast.content.0')}</p> + <p>{t('search:advancedSearch.policyToast.content.1')}</p> + </EuiText> </> ); }; @@ -661,7 +671,7 @@ const PopoverValueContent = ({ if (Array.isArray(searchFields[index].values)) { fieldValues = []; searchFields[index].values.forEach((value) => { - if (!!value) { + if (value) { fieldValues.push(value); } }); @@ -683,7 +693,7 @@ const PopoverValueContent = ({ setSearchFields(updatedSearchFields); updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); setFieldCount(updateArrayElement(fieldCount, index)); - if (searchFields[index].sources.length) { + if (searchFields[index].sources?.length) { const filteredSources = []; searchFields[index].sources.forEach((sourceId) => { let source; @@ -782,7 +792,7 @@ const PopoverValueContent = ({ }; const SelectDates = ({ i }) => { - if (!!searchFields[index].values[i].option) { + if (searchFields[index].values[i].option) { switch (searchFields[index].values[i].option) { case 'between': return ( @@ -867,7 +877,7 @@ const PopoverValueContent = ({ }; const NumericValues = ({ i }) => { - if (!!searchFields[index].values[i].option) { + if (searchFields[index].values[i].option) { switch (searchFields[index].values[i].option) { case 'between': return ( @@ -1098,13 +1108,19 @@ const PopoverValueButton = ({ const { t } = useTranslation('search'); const [isPopoverValueOpen, setIsPopoverValueOpen] = useState(false); + const handleButtonClick = (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsPopoverValueOpen((prevState) => !prevState); + }; + return ( <EuiPopover panelPaddingSize="s" button={ <EuiButtonIcon size="s" - onClick={() => setIsPopoverValueOpen(!isPopoverValueOpen)} + onClick={handleButtonClick} iconType="documentEdit" title={t('search:advancedSearch.fields.fieldContentPopover.addFieldValues')} aria-label={t( @@ -1154,19 +1170,20 @@ const FieldsPanel = ({ sources, }) => { const { t } = useTranslation('search'); + const client = useGatekeeper(); - const countFieldValues = (field, index) => { + const countFieldValues = async (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 advancedQuery = buildDslQuery(fieldStr, standardFields); + const params = { + query: advancedQuery, + sourcesId: availableSources.map((source) => source.id), + fieldsId: standardFields, + advancedQuery: true, + }; + const result = await client.searchQuery(params); + const fieldCountUpdated = updateArrayElement(fieldCount, index, result.length); + setFieldCount(fieldCountUpdated); }; const handleRemoveField = (index) => { @@ -1224,8 +1241,12 @@ const FieldsPanel = ({ updateSearch(setSearch, updatedSearchFields, selectedOperatorId, setSearchCount); }; - if (standardFields === []) { - return <h2>{t('search:advancedSearch.fields.loadingFields')}</h2>; + if (standardFields == []) { + return ( + <EuiTitle size="xs"> + <h2>{t('search:advancedSearch.fields.loadingFields')}</h2> + </EuiTitle> + ); } return ( @@ -1242,92 +1263,72 @@ const FieldsPanel = ({ key={'field' + index} paddingSize="s" > - <EuiFlexGroup direction="row" alignItems="center"> - <EuiFlexItem grow={false}> + <EuiFlexGroup + direction="row" + alignItems="center" + justifyContent={'spaceBetween'} + > + <EuiFlexGroup direction={'row'} alignItems="center" gutterSize={'m'}> + <EuiSpacer size={'s'} /> <EuiButtonIcon - size="s" + iconSize="m" color="danger" onClick={() => handleRemoveField(index)} - iconType="indexClose" + iconType="cross" 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> - {!isNaN(fieldCount[index]) && ( - <EuiFlexItem grow={false}> - <EuiTextColor color="secondary"> - {t('search:advancedSearch.resultsCount', { - count: fieldCount[index], - })} - </EuiTextColor> - </EuiFlexItem> - )} - {field.isValidated && ( - <EuiFlexItem grow={false}> - <EuiButtonIcon - size="s" - onClick={() => countFieldValues(field, index)} - iconType="number" - title={t('search:advancedSearch.countResultsButton')} - aria-label={t('search:advancedSearch.countResultsButton')} - /> - </EuiFlexItem> - )} - {field.isValidated && ( - <EuiFlexItem grow={false}> - <EuiButtonIcon - size="s" - color="danger" - onClick={() => handleClearValues(index)} - iconType="trash" - title={t('search:advancedSearch.fields.clearValues')} - aria-label={t('search:advancedSearch.fields.clearValues')} - /> - </EuiFlexItem> - )} + <EuiHealth color={field.sources?.length ? 'danger' : 'primary'}> + {field.isValidated + ? fieldValuesToString(field).replace(/_|\./g, ' ') + : field.name.replace(/_|\./g, ' ')} + </EuiHealth> + </EuiFlexGroup> <EuiFlexItem grow={false}> - <PopoverValueButton - index={index} - standardFields={standardFields} - searchFields={searchFields} - setSearchFields={setSearchFields} - setSearch={setSearch} - setSearchCount={setSearchCount} - fieldCount={fieldCount} - setFieldCount={setFieldCount} - selectedOperatorId={selectedOperatorId} - selectedSources={selectedSources} - setSelectedSources={setSelectedSources} - availableSources={availableSources} - setAvailableSources={setAvailableSources} - /> + <EuiFlexGroup direction={'row'} alignItems="center" gutterSize={'m'}> + {!isNaN(fieldCount[index]) && ( + <EuiTextColor color="secondary"> + {t('search:advancedSearch.resultsCount', { + count: fieldCount[index], + })} + </EuiTextColor> + )} + {field.isValidated && ( + <EuiButtonIcon + size="s" + onClick={() => countFieldValues(field, index)} + iconType="number" + title={t('search:advancedSearch.countResultsButton')} + aria-label={t('search:advancedSearch.countResultsButton')} + /> + )} + {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')} + /> + )} + <PopoverValueButton + index={index} + standardFields={standardFields} + searchFields={searchFields} + setSearchFields={setSearchFields} + setSearch={setSearch} + setSearchCount={setSearchCount} + fieldCount={fieldCount} + setFieldCount={setFieldCount} + selectedOperatorId={selectedOperatorId} + selectedSources={selectedSources} + setSelectedSources={setSelectedSources} + availableSources={availableSources} + setAvailableSources={setAvailableSources} + /> + <EuiSpacer size={'s'} /> + </EuiFlexGroup> </EuiFlexItem> </EuiFlexGroup> </EuiPanel> @@ -1457,7 +1458,6 @@ const AdvancedSearch = ({ setSearchResults={setSearchResults} searchFields={searchFields} setSearchFields={setSearchFields} - selectedSources={selectedSources} setSelectedSources={setSelectedSources} availableSources={availableSources} setAvailableSources={setAvailableSources} diff --git a/src/pages/search/BasicSearch/BasicSearch.js b/src/pages/search/BasicSearch/BasicSearch.js index dfdd1f16d842ba8653b675651fda7c532a0a9070..8f36200ba4551d8bb63fbc45a18761295c984796 100644 --- a/src/pages/search/BasicSearch/BasicSearch.js +++ b/src/pages/search/BasicSearch/BasicSearch.js @@ -6,15 +6,16 @@ import { EuiFlexItem, EuiSpacer, } from '@elastic/eui'; -import { createBasicQueriesBySource } from '../../../Utils'; -import { searchQuery } from '../../../actions/source'; import { useTranslation } from 'react-i18next'; import SearchModeSwitcher from '../SearchModeSwitcher'; +import { useGatekeeper } from '../../../contexts/GatekeeperContext'; +import HowToBasicSearch from './HowToBasicSearch'; +import { toast } from 'react-toastify'; +import ToastMessage from '../../../components/ToastMessage/ToastMessage'; const BasicSearch = ({ standardFields, availableSources, - selectedSources, basicSearch, setBasicSearch, setIsAdvancedSearch, @@ -22,25 +23,31 @@ const BasicSearch = ({ setSearchResults, setSelectedTabNumber, }) => { - const { t } = useTranslation('search'); + const { t } = useTranslation(['search', 'validation']); + const client = useGatekeeper(); const [isLoading, setIsLoading] = useState(false); - const onFormSubmit = (e) => { + const onFormSubmit = async (e) => { e.preventDefault(); setIsLoading(true); - const queriesWithIndices = createBasicQueriesBySource( - standardFields, - basicSearch, - selectedSources, - availableSources - ); - searchQuery(queriesWithIndices).then((result) => { + const result = await client.searchQuery({ + query: basicSearch, + sourcesId: availableSources.map((source) => source.id), + fieldsId: standardFields, + }); + setIsLoading(false); + if (!result.error) { setSearchResults(result); - if (isLoading) { - setIsLoading(false); - } setSelectedTabNumber(1); - }); + } else if (result.statusCode === 400) { + toast.error( + <ToastMessage title={t('validation:error')} message={result.message} /> + ); + } else { + toast.error( + <ToastMessage title={t('validation:error')} message={t('search:queryError')} /> + ); + } }; return ( @@ -71,6 +78,7 @@ const BasicSearch = ({ </EuiFlexItem> </EuiFlexGroup> </form> + <HowToBasicSearch /> </EuiFlexItem> </EuiFlexGroup> </> diff --git a/src/pages/search/BasicSearch/HowToBasicSearch.js b/src/pages/search/BasicSearch/HowToBasicSearch.js new file mode 100644 index 0000000000000000000000000000000000000000..1b4dd8ad82badb6e5f4b5d97487522715c492e33 --- /dev/null +++ b/src/pages/search/BasicSearch/HowToBasicSearch.js @@ -0,0 +1,120 @@ +import React from 'react'; +import { + EuiAccordion, + EuiButtonEmpty, + EuiFlexGrid, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + useGeneratedHtmlId, +} from '@elastic/eui'; +import { Trans, useTranslation } from 'react-i18next'; + +const HowToBasicSearch = () => { + const { t, ready } = useTranslation('search'); + + const Example = ({ query, desc }) => { + return ( + <EuiFlexGroup> + <EuiText color={'success'}> + <code> + <EuiIcon type="search" /> <Trans i18nKey={query} /> + </code> + </EuiText> + <EuiText> + <Trans i18nKey={desc} /> + </EuiText> + </EuiFlexGroup> + ); + }; + + const Examples = () => { + const examples = t('search:basicSearch.howTo.examples', { returnObjects: true }); + + return ( + <EuiPanel hasShadow={false} hasBorder={true}> + <EuiTitle size={'xs'}> + <p>{t('search:basicSearch.howTo.examplesTitle')}</p> + </EuiTitle> + <EuiSpacer size={'s'} /> + <EuiFlexGrid columns={1} gutterSize={'s'} direction={'column'}> + {examples.map((example, index) => { + return ( + <EuiFlexItem key={index}> + <Example query={example.query} desc={example.desc} /> + </EuiFlexItem> + ); + })} + </EuiFlexGrid> + </EuiPanel> + ); + }; + + const Section = ({ title, text }) => { + return ( + <EuiPanel hasShadow={false} hasBorder={true}> + <EuiTitle size={'xs'}> + <p>{t(title)}</p> + </EuiTitle> + <EuiPanel hasShadow={false} paddingSize={'s'}> + <EuiText> + <ul> + {text.map((item, index) => { + return ( + <li key={index}> + <Trans i18nKey={item} /> + </li> + ); + })} + </ul> + </EuiText> + </EuiPanel> + </EuiPanel> + ); + }; + + if (!ready) { + return 'Loading translations...'; + } + + const sections = t('search:basicSearch.howTo.sections', { returnObjects: true }); + + return ( + <> + <EuiSpacer size="s" /> + <EuiFlexGroup> + <EuiFlexItem> + <EuiAccordion + id={useGeneratedHtmlId({ prefix: 'howToAccordion' })} + buttonContent={ + <EuiButtonEmpty> + {t('search:basicSearch.howTo.toggleAction')} + </EuiButtonEmpty> + } + buttonElement={'div'} + > + <EuiSpacer size="s" /> + <Examples /> + <EuiSpacer size="s" /> + <EuiFlexGrid columns={2} gutterSize={'s'}> + {sections.map((section, index) => { + return ( + <EuiFlexItem key={index}> + <Section title={section.title} text={section.content} /> + </EuiFlexItem> + ); + })} + </EuiFlexGrid> + <EuiSpacer size="s" /> + </EuiAccordion> + </EuiFlexItem> + </EuiFlexGroup> + </> + ); +}; + +export default HowToBasicSearch; diff --git a/src/pages/search/Search.js b/src/pages/search/Search.js index 6f31278b60fd47d8257f778f74fc011082ab5bb5..970e5d7e5794048b4290ee8c2f208a54d483b527 100644 --- a/src/pages/search/Search.js +++ b/src/pages/search/Search.js @@ -1,19 +1,15 @@ -import React, { useState, useEffect } from 'react'; -import { EuiTabbedContent, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { EuiSpacer, EuiTabbedContent } from '@elastic/eui'; import Results from '../results/Results'; import SearchMap from '../maps/SearchMap'; -import { removeNullFields } from '../../Utils.js'; -import { - fetchPublicFields, - fetchUserPolicyFields, - fetchSources, -} from '../../actions/source'; import { useTranslation } from 'react-i18next'; import AdvancedSearch from './AdvancedSearch/AdvancedSearch'; import BasicSearch from './BasicSearch/BasicSearch'; +import { useGatekeeper } from '../../contexts/GatekeeperContext'; import ResourceFlyout from '../results/ResourceFlyout/ResourceFlyout'; const Search = () => { + const client = useGatekeeper(); const { t } = useTranslation('search'); const [selectedTabNumber, setSelectedTabNumber] = useState(0); const [isAdvancedSearch, setIsAdvancedSearch] = useState(false); @@ -29,37 +25,59 @@ const Search = () => { const [resourceFlyoutData, setResourceFlyoutData] = useState({}); useEffect(() => { - fetchPublicFields().then((resultStdFields) => { - resultStdFields.forEach((field) => { - field.sources = []; - }); - setStandardFields(resultStdFields); - fetchUserPolicyFields(sessionStorage.getItem('kcId')).then((resultPolicyFields) => { - const userFields = resultStdFields; - resultPolicyFields.forEach((polField) => { - const stdFieldIndex = userFields.findIndex( - (stdField) => stdField.id === polField.std_id - ); - if (stdFieldIndex >= 0) { - if (!userFields[stdFieldIndex].sources.includes(polField.source_id)) - userFields[stdFieldIndex].sources.push(polField.source_id); - } else { - const newField = { - id: polField.std_id, - sources: [polField.source_id], - ...polField, - }; - userFields.push(newField); - } - }); - userFields.sort((a, b) => (a.id > b.id ? 1 : b.id > a.id ? -1 : 0)); - setStandardFields(removeNullFields(userFields)); - }); - }); - fetchSources(sessionStorage.getItem('kcId')).then((result) => { - setSources(result); - setAvailableSources(result); - }); + if (!client) { + return; + } + const fetchElements = async () => { + const publicFields = await client.getPublicFields(); + setStandardFields(publicFields); + const sources = await client.getSources(); + setSources(sources); + setAvailableSources(sources); + }; + fetchElements(); + // client + // .getPublicFields() + // .then((resultStdFields) => { + // resultStdFields.forEach((field) => { + // field.sources = []; + // }); + // setStandardFields(resultStdFields); + // fetchUserPolicyFields(sessionStorage.getItem('kcId')).then( + // (resultPolicyFields) => { + // const userFields = resultStdFields; + // resultPolicyFields.forEach((polField) => { + // const stdFieldIndex = userFields.findIndex( + // (stdField) => stdField.id === polField.std_id + // ); + // if (stdFieldIndex >= 0) { + // if (!userFields[stdFieldIndex].sources.includes(polField.source_id)) + // userFields[stdFieldIndex].sources.push(polField.source_id); + // } else { + // const newField = { + // id: polField.std_id, + // sources: [polField.source_id], + // ...polField, + // }; + // userFields.push(newField); + // } + // }); + // userFields.sort((a, b) => (a.id > b.id ? 1 : b.id > a.id ? -1 : 0)); + // setStandardFields(removeNullFields(userFields)); + // } + // ); + // }) + // .catch((error) => { + // console.error(error); + // }); + // fetchSources(sessionStorage.getItem('kcId')) + // .then((result) => { + // setSources(result); + // setAvailableSources(result); + // }) + // .catch((error) => { + // console.error(error); + // }); }, []); // On new search, reset selected rows @@ -76,7 +94,7 @@ const Search = () => { const setResourceFlyoutDataFromId = (id) => { const resourceData = getResourceDataFromRowId(id); // Extract all values except for id to avoid displaying it to user - setResourceFlyoutData((({ id, ...rest }) => rest)(resourceData)); + setResourceFlyoutData((({ id, ...rest }) => rest)(resourceData)); // eslint-disable-line }; const tabsContent = [ @@ -84,77 +102,72 @@ const Search = () => { id: 'tab1', name: t('search:tabs.composeSearch'), content: ( - <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} - /> - )} - </EuiFlexItem> - </EuiFlexGroup> + <> + <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} + basicSearch={basicSearch} + setBasicSearch={setBasicSearch} + setSearchResults={setSearchResults} + setSelectedTabNumber={setSelectedTabNumber} + /> + )} + </> ), }, { id: 'tab2', name: t('search:tabs.results'), content: ( - <EuiFlexGroup> - <EuiFlexItem> - <EuiSpacer size="l" /> - <Results - searchResults={searchResults} - searchQuery={isAdvancedSearch ? search : basicSearch} - selectedRowsIds={selectedResultsRowsIds} - setSelectedRowsIds={setSelectedResultsRowsIds} - setResourceFlyoutDataFromId={setResourceFlyoutDataFromId} - setIsResourceFlyoutOpen={setIsResourceFlyoutOpen} - /> - </EuiFlexItem> - </EuiFlexGroup> + <> + <EuiSpacer size={'l'} /> + <Results + searchResults={searchResults} + searchQuery={isAdvancedSearch ? search : basicSearch} + selectedRowsIds={selectedResultsRowsIds} + setSelectedRowsIds={setSelectedResultsRowsIds} + setResourceFlyoutDataFromId={setResourceFlyoutDataFromId} + setIsResourceFlyoutOpen={setIsResourceFlyoutOpen} + setSelectedTabNumber={setSelectedTabNumber} + /> + </> ), }, { id: 'tab3', name: t('search:tabs.map'), content: ( - <EuiFlexGroup> - <EuiFlexItem> - <EuiSpacer size="l" /> - <SearchMap - searchResults={searchResults} - selectedPointsIds={selectedResultsRowsIds} - setSelectedPointsIds={setSelectedResultsRowsIds} - setResourceFlyoutDataFromId={setResourceFlyoutDataFromId} - setIsResourceFlyoutOpen={setIsResourceFlyoutOpen} - /> - </EuiFlexItem> - </EuiFlexGroup> + <> + <EuiSpacer size={'l'} /> + <SearchMap + searchResults={searchResults} + selectedPointsIds={selectedResultsRowsIds} + setSelectedPointsIds={setSelectedResultsRowsIds} + setResourceFlyoutDataFromId={setResourceFlyoutDataFromId} + setIsResourceFlyoutOpen={setIsResourceFlyoutOpen} + setSelectedTabNumber={setSelectedTabNumber} + /> + </> ), }, ]; diff --git a/src/services/GatekeeperService.js b/src/services/GatekeeperService.js new file mode 100644 index 0000000000000000000000000000000000000000..f13dc9192c3a1f2e6bcdcc158c7623dab405a109 --- /dev/null +++ b/src/services/GatekeeperService.js @@ -0,0 +1,153 @@ +export class InSylvaGatekeeperClient { + constructor(getUserInfo) { + this.getUserInfo = getUserInfo; + } + async get(path, payload) { + const userInfo = await this.getUserInfo(); + const { access_token } = userInfo; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${access_token}`, + }; + const response = await fetch( + `${process.env.REACT_APP_IN_SYLVA_GATEKEEPER_BASE_URL}${path}`, + { + method: 'GET', + headers, + mode: 'cors', + body: JSON.stringify(payload), + } + ); + return await response.json(); + } + + async post(path, requestContent) { + const userInfo = await this.getUserInfo(); + const { access_token } = userInfo; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${access_token}`, + }; + const response = await fetch( + `${process.env.REACT_APP_IN_SYLVA_GATEKEEPER_BASE_URL}${path}`, + { + method: 'POST', + headers, + body: JSON.stringify(requestContent), + mode: 'cors', + } + ); + return await response.json(); + } + + async delete(path, requestContent) { + const userInfo = await this.getUserInfo(); + const { access_token } = userInfo; + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${access_token}`, + }; + const response = await fetch( + `${process.env.REACT_APP_IN_SYLVA_GATEKEEPER_BASE_URL}${path}`, + { + method: 'DELETE', + headers, + body: JSON.stringify(requestContent), + mode: 'cors', + } + ); + if (response.status === 204) { + return { + success: true, + message: 'Request deleted successfully', + }; + } else { + return { + success: false, + message: 'Request not deleted', + }; + } + } + + async createUser(sub, email) { + const path = `/users`; + return await this.post(path, { + kc_id: sub, + email, + }); + } + + async findUserBySub(sub) { + const path = `/users/${sub}`; + return await this.get(path); + } + + async getRoles() { + const path = `/roles`; + return await this.get(path); + } + + async createUserRequest(message, sub) { + const path = `/users/${sub}/requests`; + return await this.post(path, { + message: message, + }); + } + + async deleteUserRequest(id) { + const path = `/user-requests/${id}`; + return await this.delete(path, {}); + } + + async getGroups() { + const path = `/groups`; + return await this.get(path); + } + + async getPublicFields() { + const path = `/public_std_fields`; + return await this.get(path); + } + + async getUserFieldsDisplaySettings(sub) { + const path = `/users/${sub}/fields`; + return await this.get(path); + } + + async setUserFieldsDisplaySettings(sub, fields) { + const path = `/users/${sub}/fields`; + return await this.post(path, { + fields_id: fields, + }); + } + + async getDefaultFieldsDisplaySettings() { + const path = `/std_fields/default`; + return await this.get(path); + } + + async getSources() { + const path = `/sources`; + return await this.get(path); + } + + async getUserSources(sub) { + const path = `/users/${sub}/sources`; + return await this.get(path); + } + + async searchQuery(payload) { + const path = `/search`; + return await this.post(path, payload); + } + + async getUserHistory(sub) { + const path = `/users/${sub}/history`; + return await this.get(path); + } + + async addHistory(sub, payload) { + const path = `/users/${sub}/history`; + return await this.post(path, payload); + } +} diff --git a/src/services/TokenService.js b/src/services/TokenService.js new file mode 100644 index 0000000000000000000000000000000000000000..4ead0437e0a703af625156f75479432cdd855ec2 --- /dev/null +++ b/src/services/TokenService.js @@ -0,0 +1,38 @@ +export default class TokenService { + async getUserInfo(access_token) { + const issuerUrl = process.env.REACT_APP_KEYCLOAK_BASE_URL; + const userInfoEndpoint = issuerUrl + '/protocol/openid-connect/userinfo'; + + const response = await fetch(userInfoEndpoint, { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }); + if (response.status !== 200) { + return null; + } else { + const userInfo = await response.json(); + return userInfo; + } + } + + async refreshToken(refresh_token) { + const issuerUrl = process.env.REACT_APP_KEYCLOAK_BASE_URL; + const tokenEndpoint = issuerUrl + '/protocol/openid-connect/token'; + + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: `grant_type=refresh_token&refresh_token=${refresh_token}&client_id=${process.env.REACT_APP_KEYCLOAK_CLIENT_ID}&client_secret=${process.env.REACT_APP_KEYCLOAK_CLIENT_SECRET}`, + }); + + if (response.status !== 200) { + return null; + } else { + const response = await response.json(); + return response; + } + } +}