From 1f47dfc046cd8025ff75a7a7b033dfa615010d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aubin=20Lambar=C3=A9?= Date: Tue, 4 Mar 2025 16:33:15 +0100 Subject: [PATCH] refactor: correct linter issues --- .eslintrc.js | 3 +- eodag_labextension/handlers.py | 70 +- src/CodeGenerator.ts | 42 +- src/SearchService.ts | 12 +- src/browser.tsx | 2 +- .../AdditionalParameterFields.tsx | 222 ++-- src/formComponent/DropdownButton.tsx | 163 +-- src/formComponent/FormComponent.tsx | 1069 +++++++++-------- src/formComponent/ParameterGroup.tsx | 100 +- src/handler.ts | 5 +- src/helpers/fetchQueryables.ts | 133 +- src/hooks/useFetchData.ts | 6 +- src/icones.tsx | 2 +- style/base.css | 77 +- 14 files changed, 1013 insertions(+), 893 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 9468611..665374b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -20,8 +20,7 @@ module.exports = { custom: { regex: '^I[A-Z]', match: true - }, - 'prettier/prettier': ['error', { endOfLine: 'auto' }] + } } ], '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], diff --git a/eodag_labextension/handlers.py b/eodag_labextension/handlers.py index 68d43a1..edb5887 100644 --- a/eodag_labextension/handlers.py +++ b/eodag_labextension/handlers.py @@ -39,11 +39,7 @@ def get(self): query_dict = parse_qs(self.request.query) provider = None - if ( - "provider" in query_dict - and isinstance(query_dict["provider"], list) - and len(query_dict["provider"]) > 0 - ): + if "provider" in query_dict and isinstance(query_dict["provider"], list) and len(query_dict["provider"]) > 0: provider = query_dict.pop("provider")[0] provider = None if not provider or provider == "null" else provider @@ -81,9 +77,7 @@ def get(self): and len(query_dict["product_type"]) > 0 ): available_providers_kwargs["product_type"] = query_dict["product_type"][0] - available_providers = eodag_api.available_providers( - **available_providers_kwargs - ) + available_providers = eodag_api.available_providers(**available_providers_kwargs) all_providers_list = [ dict( @@ -98,26 +92,17 @@ def get(self): all_providers_list.sort(key=lambda x: (x["priority"] * -1, x["provider"])) returned_providers = [] - if ( - "keywords" in query_dict - and isinstance(query_dict["keywords"], list) - and len(query_dict["keywords"]) > 0 - ): + if "keywords" in query_dict and isinstance(query_dict["keywords"], list) and len(query_dict["keywords"]) > 0: # 1. List providers starting with given keyword first_keyword = query_dict["keywords"][0].lower() - returned_providers = [ - p - for p in all_providers_list - if p["provider"].lower().startswith(first_keyword) - ] + returned_providers = [p for p in all_providers_list if p["provider"].lower().startswith(first_keyword)] providers_ids = [p["provider"] for p in returned_providers] # 2. List providers containing given keyword returned_providers += [ p for p in all_providers_list - if first_keyword in p["provider"].lower() - and p["provider"] not in providers_ids + if first_keyword in p["provider"].lower() and p["provider"] not in providers_ids ] providers_ids = [p["provider"] for p in returned_providers] @@ -125,8 +110,7 @@ def get(self): returned_providers += [ p for p in all_providers_list - if first_keyword in (p["description"] or "").lower() - and p["provider"] not in providers_ids + if first_keyword in (p["description"] or "").lower() and p["provider"] not in providers_ids ] else: returned_providers = all_providers_list @@ -144,11 +128,7 @@ def get(self): query_dict = parse_qs(self.request.query) provider = None - if ( - "provider" in query_dict - and isinstance(query_dict["provider"], list) - and len(query_dict["provider"]) > 0 - ): + if "provider" in query_dict and isinstance(query_dict["provider"], list) and len(query_dict["provider"]) > 0: provider = query_dict.pop("provider")[0] provider = None if not provider or provider == "null" else provider @@ -164,31 +144,22 @@ def get(self): ): # 1. List product types starting with given keywords first_keyword = query_dict["keywords"][0].lower() - returned_product_types = [ - pt - for pt in all_product_types - if pt["ID"].lower().startswith(first_keyword) - ] + returned_product_types = [pt for pt in all_product_types if pt["ID"].lower().startswith(first_keyword)] returned_product_types_ids = [pt["ID"] for pt in returned_product_types] # 2. List product types containing keyword returned_product_types += [ pt for pt in all_product_types - if first_keyword in pt["ID"].lower() - and pt["ID"] not in returned_product_types_ids - ] - returned_product_types_ids += [ - pt["ID"] for pt in returned_product_types + if first_keyword in pt["ID"].lower() and pt["ID"] not in returned_product_types_ids ] + returned_product_types_ids += [pt["ID"] for pt in returned_product_types] # 3. Append guessed product types guess_kwargs = {} # ["aa bb", "cc-dd_ee"] to "*aa* AND *bb* AND *cc-dd_ee*" for k, v in query_dict.items(): - guess_kwargs[k] = " AND ".join( - re.sub(r"(\S+)", r"*\1*", " ".join(v)).split(" ") - ) + guess_kwargs[k] = " AND ".join(re.sub(r"(\S+)", r"*\1*", " ".join(v)).split(" ")) # guessed product types ids guessed_ids_list = eodag_api.guess_product_type(**guess_kwargs) @@ -196,8 +167,7 @@ def get(self): returned_product_types += [ pt for pt in all_product_types - if pt["ID"] in guessed_ids_list - and pt["ID"] not in returned_product_types_ids + if pt["ID"] in guessed_ids_list and pt["ID"] not in returned_product_types_ids ] else: returned_product_types = all_product_types @@ -246,9 +216,7 @@ def post(self, product_type): arguments = dict((k, v) for k, v in arguments.items() if v is not None) try: - products = eodag_api.search( - productType=product_type, count=True, **arguments - ) + products = eodag_api.search(productType=product_type, count=True, **arguments) except ValidationError as e: self.set_status(400) self.finish({"error": e.message}) @@ -263,9 +231,7 @@ def post(self, product_type): return except AuthenticationError as e: self.set_status(403) - self.finish( - {"error": f"AuthenticationError: Please check your credentials ({e})"} - ) + self.finish({"error": f"AuthenticationError: Please check your credentials ({e})"}) return except Exception as e: self.set_status(502) @@ -332,9 +298,7 @@ def get(self): } logger.error(queryables_kwargs) try: - queryables_dict = eodag_api.list_queryables( - fetch_providers=False, **queryables_kwargs - ) + queryables_dict = eodag_api.list_queryables(fetch_providers=False, **queryables_kwargs) json_schema = queryables_dict.get_model().model_json_schema() self._remove_null_defaults(json_schema) json_schema["additionalProperties"] = queryables_dict.additional_properties @@ -360,9 +324,7 @@ def setup_handlers(web_app, url_path): product_types_pattern = url_path_join(base_url, url_path, "product-types") reload_pattern = url_path_join(base_url, url_path, "reload") providers_pattern = url_path_join(base_url, url_path, "providers") - guess_product_types_pattern = url_path_join( - base_url, url_path, "guess-product-type" - ) + guess_product_types_pattern = url_path_join(base_url, url_path, "guess-product-type") queryables_pattern = url_path_join(base_url, url_path, "queryables") search_pattern = url_path_join(base_url, url_path, r"(?P[\w\-\.]+)") default_pattern = url_path_join(base_url, url_path, r".*") diff --git a/src/CodeGenerator.ts b/src/CodeGenerator.ts index 7f030d0..60ed9ef 100644 --- a/src/CodeGenerator.ts +++ b/src/CodeGenerator.ts @@ -66,34 +66,48 @@ search_results = dag.search(`; } const filteredParameters = additionnalParameters.filter( ({ name, value }) => name && value && name !== '' && value !== '' - ) + ); - const extraParamEntries = Object.entries(extraParams).filter(([_, value]) => value !== undefined); + const extraParamEntries = Object.entries(extraParams).filter( + ([_, value]) => value !== undefined + ); if (filteredParameters.length > 0 || extraParamEntries.length > 0) { code += '\n' + tab + '**{\n'; // Map additionnalParameters - code += filteredParameters.map(({ name, value }) => { - const processedValue = Array.isArray(value) - ? `[${value.map((item: any) => (typeof item === 'string' ? `"${item.trim()}"` : item)).join(', ')}]` - : typeof value === 'string' + code += filteredParameters + .map(({ name, value }) => { + const processedValue = Array.isArray(value) + ? `[${value + .map((item: any) => + typeof item === 'string' ? `"${item.trim()}"` : item + ) + .join(', ')}]` + : typeof value === 'string' ? `"${value.trim()}"` : value; - return `${tab + tab}"${name}": ${processedValue},`; - }).join('\n'); + return `${tab + tab}"${name}": ${processedValue},`; + }) + .join('\n'); // Map extra parameters dynamically if (extraParamEntries.length > 0) { if (filteredParameters.length > 0) code += '\n'; // Separate sections - code += extraParamEntries.map(([key, value]) => { - const processedValue = Array.isArray(value) - ? `[${value.map((item: any) => (typeof item === 'string' ? `"${item.trim()}"` : item)).join(', ')}]` - : typeof value === 'string' + code += extraParamEntries + .map(([key, value]) => { + const processedValue = Array.isArray(value) + ? `[${value + .map((item: any) => + typeof item === 'string' ? `"${item.trim()}"` : item + ) + .join(', ')}]` + : typeof value === 'string' ? `"${value.trim()}"` : value; - return `${tab + tab}"${key}": ${processedValue},`; - }).join('\n'); + return `${tab + tab}"${key}": ${processedValue},`; + }) + .join('\n'); } code += '\n' + `${tab}}`; // Close dictionary diff --git a/src/SearchService.ts b/src/SearchService.ts index cdf5645..fc25f22 100644 --- a/src/SearchService.ts +++ b/src/SearchService.ts @@ -57,12 +57,12 @@ class SearchService { // Map any extra dynamic properties (excluding already handled ones) const excludedKeys = new Set([ - "startDate", - "endDate", - "productType", - "geometry", - "provider", - "additionnalParameters" + 'startDate', + 'endDate', + 'productType', + 'geometry', + 'provider', + 'additionnalParameters' ]); Object.keys(formValues).forEach(key => { diff --git a/src/browser.tsx b/src/browser.tsx index 591dee3..3b260bb 100644 --- a/src/browser.tsx +++ b/src/browser.tsx @@ -154,7 +154,7 @@ export class EodagBrowser extends React.Component { const searchString = '# Code generated by eodag-labextension,'; const isReplaceCellExist = cells.filter(cell => cell.node.innerText.includes(searchString)).length > - 0 + 0 ? true : false; diff --git a/src/formComponent/AdditionalParameterFields.tsx b/src/formComponent/AdditionalParameterFields.tsx index 28a3b24..bc83965 100644 --- a/src/formComponent/AdditionalParameterFields.tsx +++ b/src/formComponent/AdditionalParameterFields.tsx @@ -1,110 +1,112 @@ -import React from 'react'; -import { useFieldArray, UseFormReturn } from 'react-hook-form'; -import { Tooltip } from 'react-tooltip'; -import { CarbonAddFilled, CarbonInformation, CarbonTrashCan } from '../icones'; -import { IFormInput } from '../types'; -import { tooltipDark, tooltipTop, tooltipWarning } from './FormComponent'; - -export interface AdditionalParameterFieldsProps - extends Partial> { - productType: string; - additionalParameters: boolean; -} - -const AdditionalParameterFields = ({ - control, - register, - resetField, - productType, - additionalParameters -}: AdditionalParameterFieldsProps) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const { fields, append, remove, update } = useFieldArray({ - control, - name: 'additionnalParameters' - }); - fields[0] = { name: '', value: '', id: '999' }; - - const clearInput = (index: number): void => { - resetField(`additionnalParameters.${index}.name`); - resetField(`additionnalParameters.${index}.value`); - }; - return ( - <> -
-
- - - - - -
- - {!productType ? ( -

- Select a product type to unlock additional parameters. -

- ) : - additionalParameters ? ( - fields.map((field, index) => ( -
-
- - - - -
-
- )) - ) : ( -

- Additional parameters are not allowed with this product type. -

- )} -
- - ); -}; - -export default AdditionalParameterFields; +import React from 'react'; +import { useFieldArray, UseFormReturn } from 'react-hook-form'; +import { Tooltip } from 'react-tooltip'; +import { CarbonAddFilled, CarbonInformation, CarbonTrashCan } from '../icones'; +import { IFormInput } from '../types'; +import { tooltipDark, tooltipTop, tooltipWarning } from './FormComponent'; + +export interface AdditionalParameterFieldsProps + extends Partial> { + productType: string; + additionalParameters: boolean; +} + +const AdditionalParameterFields = ({ + control, + register, + resetField, + productType, + additionalParameters +}: AdditionalParameterFieldsProps) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const { fields, append, remove, update } = useFieldArray({ + control, + name: 'additionnalParameters' + }); + fields[0] = { name: '', value: '', id: '999' }; + + const clearInput = (index: number): void => { + resetField(`additionnalParameters.${index}.name`); + resetField(`additionnalParameters.${index}.value`); + }; + return ( + <> +
+
+ + + + + +
+ + {!productType ? ( +

+ Select a product type to unlock additional parameters. +

+ ) : additionalParameters ? ( + fields.map((field, index) => ( +
+
+ + + + +
+
+ )) + ) : ( +

+ Additional parameters are not allowed with this product type. +

+ )} +
+ + ); +}; + +export default AdditionalParameterFields; diff --git a/src/formComponent/DropdownButton.tsx b/src/formComponent/DropdownButton.tsx index be9612b..7796c6c 100644 --- a/src/formComponent/DropdownButton.tsx +++ b/src/formComponent/DropdownButton.tsx @@ -1,88 +1,97 @@ -import React, { useState, useRef, useEffect } from "react"; -import { OptionType } from "../types"; -import { CarbonAddFilled } from "../icones"; - +import React, { useState, useRef, useEffect } from 'react'; +import { OptionType } from '../types'; +import { CarbonAddFilled } from '../icones'; // Props for DropdownButton component interface DropdownButtonProps { - options: OptionType[]; - onSelect: (option: OptionType) => void; - selectedOptions: string[]; - buttonLabel?: string; - disabled?: boolean; + options: OptionType[]; + onSelect: (option: OptionType) => void; + selectedOptions: string[]; + buttonLabel?: string; + disabled?: boolean; } -const DropdownButton: React.FC = ( - { options, onSelect, selectedOptions, buttonLabel = "More parameters", disabled = false } -) => { - const [showDropdown, setShowDropdown] = useState(false); - const dropdownRef = useRef(null); +const DropdownButton: React.FC = ({ + options, + onSelect, + selectedOptions, + buttonLabel = 'More parameters', + disabled = false +}) => { + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setShowDropdown(false); - } - }; - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setShowDropdown(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); - return ( -
- {/* Button */} - + return ( +
+ {/* Button */} + - {/* Dropdown Menu */} - { - showDropdown && ( -
-
    - {options.map((option) => ( -
  • onSelect(option)} - > - - {option.label} -
  • - ))} -
-
- ) - } -
- ); + {/* Dropdown Menu */} + {showDropdown && ( +
+
    + {options.map(option => ( +
  • onSelect(option)} + > + + {option.label} +
  • + ))} +
+
+ )} +
+ ); }; export default DropdownButton; diff --git a/src/formComponent/FormComponent.tsx b/src/formComponent/FormComponent.tsx index 61c6755..5604f81 100644 --- a/src/formComponent/FormComponent.tsx +++ b/src/formComponent/FormComponent.tsx @@ -1,515 +1,554 @@ -/* - * Copyright 2022 CS GROUP - France, http://www.c-s.fr - * All rights reserved - */ - -import { showErrorMessage } from '@jupyterlab/apputils'; -import 'isomorphic-fetch'; -import React, { FC, useEffect, useState } from 'react'; -import DatePicker from 'react-datepicker'; -import 'react-datepicker/dist/react-datepicker.css'; -import { Controller, FormProvider, SubmitHandler, useForm } from 'react-hook-form'; -import { ThreeDots } from 'react-loader-spinner'; -import { PlacesType, Tooltip, VariantType } from 'react-tooltip'; -import Autocomplete from '../Autocomplete'; - -import { fetchQueryables } from '../helpers/fetchQueryables'; -import { useFetchProduct, useFetchProvider } from '../hooks/useFetchData'; -import { ServerConnection } from '@jupyterlab/services'; -import { - CarbonCalendarAddAlt, - CodiconOpenPreview, - PhFileCode -} from '../icones.js'; -import MapExtentComponent from '../MapExtentComponent'; -import SearchService from '../SearchService'; -import { IFormInput, OptionType, Parameter } from '../types'; -import AdditionalParameterFields from './AdditionalParameterFields'; -import ParameterGroup from './ParameterGroup'; -import DropdownButton from './DropdownButton'; - -export interface IProps { - handleShowFeature: any; - saveFormValues: (formValue: IFormInput) => void; - handleGenerateQuery: any; - isNotebookCreated: any; - reloadIndicator: boolean; - onFetchComplete: () => void; -} - -export interface IOptionTypeBase { - [key: string]: any; -} - -export interface IProduct { - ID: string; - title: string; -} - -export interface IProvider { - provider: string; - description: string; -} - -export const tooltipDark: VariantType = 'dark'; -export const tooltipWarning: VariantType = 'warning'; -export const tooltipTop: PlacesType = 'top'; - -export const FormComponent: FC = ({ - handleShowFeature, - saveFormValues, - handleGenerateQuery, - isNotebookCreated, - reloadIndicator, - onFetchComplete -}) => { - const [productTypes, setProductTypes] = useState(); - const [providers, setProviders] = useState(); - const defaultStartDate: Date = undefined; - const defaultEndDate: Date = undefined; - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [isLoadingSearch, setIsLoadingSearch] = useState(false); - const [openModal, setOpenModal] = useState(true); - const [providerValue, setProviderValue] = useState(null); - const [productTypeValue, setProductTypeValue] = useState(null); - const [fetchCount, setFetchCount] = useState(0); - const [params, setParams] = useState(null); - const [loading, setLoading] = useState(false); - const [additionalParameters, setAdditionalParameters] = - useState(true); - const [optionalParams, setOptionalParams] = useState([]); - - const formInput = useForm({ - defaultValues: { - startDate: defaultStartDate, - endDate: defaultEndDate, - } - }); - - const { - control, - handleSubmit, - // clearErrors, - register, - reset, - resetField, - // formState: { errors }, - getValues, - setValue, - } = formInput; - - const formValues = getValues() - - useEffect(() => { - if (!reloadIndicator) { - setFetchCount(0); - } - }, [reloadIndicator]); - - useEffect(() => { - const fetchData = async () => { - const fetchProduct = useFetchProduct(); - const productList = await fetchProduct(providerValue); - setProductTypes(productList); - if (reloadIndicator) { - setFetchCount(fetchCount => fetchCount + 1); - } - }; - fetchData(); - }, [providerValue, reloadIndicator]); - - useEffect(() => { - const fetchData = async () => { - const fetchProvider = useFetchProvider(); - const providerList = await fetchProvider(productTypeValue); - - setProviders(providerList); - if (reloadIndicator) { - setFetchCount(fetchCount => fetchCount + 1); - } - }; - - fetchData(); - }, [productTypeValue, reloadIndicator]); - - useEffect(() => { - if (fetchCount === 2) { - onFetchComplete(); - } - }, [fetchCount, onFetchComplete]); - - const onSubmit: SubmitHandler = data => { - if (!isNotebookCreated()) { - return; - } - - saveFormValues(data); - - if (!openModal) { - handleGenerateQuery(params); - } - - if (openModal) { - setIsLoadingSearch(true); - SearchService.search(1, data) - .then(featureCollection => { - if (featureCollection?.features?.length === 0) { - throw new Error('No result found'); - } else { - return featureCollection; - } - }) - .then(featureCollection => { - setIsLoadingSearch(false); - handleShowFeature(featureCollection, openModal); - if (!openModal) { - handleGenerateQuery(params); - } - }) - .catch(error => { - showErrorMessage('Bad response from server:', error); - setIsLoadingSearch(false); - }); - } - }; - - const loadProductTypesSuggestions = useFetchProduct(); - const loadProviderSuggestions = useFetchProvider(); - - const fetchParameters = async ( - query_params: { [key: string]: any } | undefined = undefined, - ): Promise => { - let queryables; - - setLoading(true); - - // Isolate the fetch queryables call and handle errors specifically for it - try { - queryables = await fetchQueryables(providerValue, productTypeValue, query_params); - } catch (error) { - if (error instanceof ServerConnection.ResponseError) { - showErrorMessage('Bad response from server:', error); - } else { - console.error("Error fetching queryables:", error); - } - return; - } - setParams(queryables.properties); - - setAdditionalParameters(queryables.additionalProperties); - - setLoading(false); - - return queryables.properties; - }; - - useEffect(() => { - if (productTypeValue) { - fetchParameters().then((params) => { - const defaultValues = params.reduce((acc: { [key: string]: any }, param: Parameter) => { - // Ensure param.value contains a 'default' property before accessing it - if (param.value && 'default' in param.value) { - acc[param.key] = param.value.default; - } - return acc; - }, {}); - - reset({ - ...defaultValues, - provider: formValues.provider, - productType: formValues.productType, - additionnalParameters: formValues.additionnalParameters - }); - - const optionals = params.filter((param) => param.mandatory === false).map((param) => ({ value: param.key, label: param.value.title ?? param.key })); - setOptionalParams(optionals) - }).catch(error => { - console.error("Error fetching parameters:", error); - }); - } - }, [providerValue, productTypeValue]); - - useEffect(() => { - if (!params || additionalParameters || !productTypeValue || loading) return; - - const query_params = params ? params.reduce((acc: { [key: string]: string }, curr: any) => { - acc[curr.key] = curr.value.selected; - return acc; - }, {} as { [key: string]: string }) : undefined; - - fetchParameters(query_params); - }, [params]); - - const [selectedOptions, setSelectedOptions] = useState([]); - - const handleSelectDropdown = (param: OptionType): void => { - if (selectedOptions.includes(param.value)) { - setSelectedOptions(selectedOptions.filter(option => option !== param.value)); - } else { - setSelectedOptions([...selectedOptions, param.value]); - } - }; - - const renderNoParamsMessage = () => ( -
-

Select a product type to unlock parameters.

-
- ); - - const renderParameterGroups = () => ( - <> - {params.some(param => param.mandatory) || selectedOptions.length > 0 ? ( - <> - - - - ) : ( -
-

No required parameter for this product type.

-
- )} - - ); - - - return ( -
- -
-
- ( - - )} - /> -
-
- ( - - loadProviderSuggestions(null, inputValue) - } - handleChange={(e: IOptionTypeBase | null) => { - onChange(e?.value); - setProviderValue(e?.value); - }} - /> - )} - /> - ( - - loadProductTypesSuggestions(providerValue, inputValue) - } - handleChange={(e: IOptionTypeBase | null) => { - onChange(e?.value); - - if (e?.value !== productTypeValue) { - setSelectedOptions([]); - setValue('additionnalParameters', [{ name: '', value: '' }]); - } - - setProductTypeValue(e?.value); - if (e?.value === undefined) { - setParams([]) - setOptionalParams([]) - reset({ - provider: formValues.provider, - additionnalParameters: formValues.additionnalParameters - }); - } - }} - /> - )} - /> -
- -
-
- - ( - { - setStartDate(d); - onChange(d); - }} - onBlur={onBlur} - selected={value} - dateFormat={'dd/MM/yyyy'} - showMonthDropdown - showYearDropdown - dropdownMode="select" - isClearable - placeholderText="Start" - /> - )} - /> -
- -
- - ( - { - setEndDate(d); - onChange(d); - }} - onBlur={onBlur} - selected={value} - dateFormat={'dd/MM/yyyy'} - showMonthDropdown - showYearDropdown - dropdownMode="select" - isClearable - placeholderText="End" - /> - )} - /> -
-
-
- -
-
-

Parameters

- -
-
- {!params || !params.length ? renderNoParamsMessage() : renderParameterGroups()} -
-
- - - -
-
-
- {isLoadingSearch ? ( -
-

Generating

- -
- ) : ( - <> -
- -
-
- -
- - )} -
-
-
-
-
- ); -}; +/* + * Copyright 2022 CS GROUP - France, http://www.c-s.fr + * All rights reserved + */ + +import { showErrorMessage } from '@jupyterlab/apputils'; +import 'isomorphic-fetch'; +import React, { FC, useEffect, useState } from 'react'; +import DatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; +import { + Controller, + FormProvider, + SubmitHandler, + useForm +} from 'react-hook-form'; +import { ThreeDots } from 'react-loader-spinner'; +import { PlacesType, Tooltip, VariantType } from 'react-tooltip'; +import Autocomplete from '../Autocomplete'; + +import { fetchQueryables } from '../helpers/fetchQueryables'; +import { useFetchProduct, useFetchProvider } from '../hooks/useFetchData'; +import { ServerConnection } from '@jupyterlab/services'; +import { + CarbonCalendarAddAlt, + CodiconOpenPreview, + PhFileCode +} from '../icones.js'; +import MapExtentComponent from '../MapExtentComponent'; +import SearchService from '../SearchService'; +import { IFormInput, OptionType, Parameter } from '../types'; +import AdditionalParameterFields from './AdditionalParameterFields'; +import ParameterGroup from './ParameterGroup'; +import DropdownButton from './DropdownButton'; + +export interface IProps { + handleShowFeature: any; + saveFormValues: (formValue: IFormInput) => void; + handleGenerateQuery: any; + isNotebookCreated: any; + reloadIndicator: boolean; + onFetchComplete: () => void; +} + +export interface IOptionTypeBase { + [key: string]: any; +} + +export interface IProduct { + ID: string; + title: string; +} + +export interface IProvider { + provider: string; + description: string; +} + +export const tooltipDark: VariantType = 'dark'; +export const tooltipWarning: VariantType = 'warning'; +export const tooltipTop: PlacesType = 'top'; + +export const FormComponent: FC = ({ + handleShowFeature, + saveFormValues, + handleGenerateQuery, + isNotebookCreated, + reloadIndicator, + onFetchComplete +}) => { + const [productTypes, setProductTypes] = useState(); + const [providers, setProviders] = useState(); + const defaultStartDate: Date = undefined; + const defaultEndDate: Date = undefined; + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [isLoadingSearch, setIsLoadingSearch] = useState(false); + const [openModal, setOpenModal] = useState(true); + const [providerValue, setProviderValue] = useState(null); + const [productTypeValue, setProductTypeValue] = useState(null); + const [fetchCount, setFetchCount] = useState(0); + const [params, setParams] = useState(null); + const [loading, setLoading] = useState(false); + const [additionalParameters, setAdditionalParameters] = + useState(true); + const [optionalParams, setOptionalParams] = useState([]); + + const formInput = useForm({ + defaultValues: { + startDate: defaultStartDate, + endDate: defaultEndDate + } + }); + + const { + control, + handleSubmit, + // clearErrors, + register, + reset, + resetField, + // formState: { errors }, + getValues, + setValue + } = formInput; + + const formValues = getValues(); + + useEffect(() => { + if (!reloadIndicator) { + setFetchCount(0); + } + }, [reloadIndicator]); + + useEffect(() => { + const fetchData = async () => { + const fetchProduct = useFetchProduct(); + const productList = await fetchProduct(providerValue); + setProductTypes(productList); + if (reloadIndicator) { + setFetchCount(fetchCount => fetchCount + 1); + } + }; + fetchData(); + }, [providerValue, reloadIndicator]); + + useEffect(() => { + const fetchData = async () => { + const fetchProvider = useFetchProvider(); + const providerList = await fetchProvider(productTypeValue); + + setProviders(providerList); + if (reloadIndicator) { + setFetchCount(fetchCount => fetchCount + 1); + } + }; + + fetchData(); + }, [productTypeValue, reloadIndicator]); + + useEffect(() => { + if (fetchCount === 2) { + onFetchComplete(); + } + }, [fetchCount, onFetchComplete]); + + const onSubmit: SubmitHandler = data => { + if (!isNotebookCreated()) { + return; + } + + saveFormValues(data); + + if (!openModal) { + handleGenerateQuery(params); + } + + if (openModal) { + setIsLoadingSearch(true); + SearchService.search(1, data) + .then(featureCollection => { + if (featureCollection?.features?.length === 0) { + throw new Error('No result found'); + } else { + return featureCollection; + } + }) + .then(featureCollection => { + setIsLoadingSearch(false); + handleShowFeature(featureCollection, openModal); + if (!openModal) { + handleGenerateQuery(params); + } + }) + .catch(error => { + showErrorMessage('Bad response from server:', error); + setIsLoadingSearch(false); + }); + } + }; + + const loadProductTypesSuggestions = useFetchProduct(); + const loadProviderSuggestions = useFetchProvider(); + + const fetchParameters = async ( + query_params: { [key: string]: any } | undefined = undefined + ): Promise => { + let queryables; + + setLoading(true); + + // Isolate the fetch queryables call and handle errors specifically for it + try { + queryables = await fetchQueryables( + providerValue, + productTypeValue, + query_params + ); + } catch (error) { + if (error instanceof ServerConnection.ResponseError) { + showErrorMessage('Bad response from server:', error); + } else { + console.error('Error fetching queryables:', error); + } + return; + } + setParams(queryables.properties); + + setAdditionalParameters(queryables.additionalProperties); + + setLoading(false); + + return queryables.properties; + }; + + useEffect(() => { + if (productTypeValue) { + fetchParameters() + .then(params => { + const defaultValues = params.reduce( + (acc: { [key: string]: any }, param: Parameter) => { + // Ensure param.value contains a 'default' property before accessing it + if (param.value && 'default' in param.value) { + acc[param.key] = param.value.default; + } + return acc; + }, + {} + ); + + reset({ + ...defaultValues, + provider: formValues.provider, + productType: formValues.productType, + additionnalParameters: formValues.additionnalParameters + }); + + const optionals = params + .filter(param => param.mandatory === false) + .map(param => ({ + value: param.key, + label: param.value.title ?? param.key + })); + setOptionalParams(optionals); + }) + .catch(error => { + console.error('Error fetching parameters:', error); + }); + } + }, [providerValue, productTypeValue]); + + useEffect(() => { + if (!params || additionalParameters || !productTypeValue || loading) return; + + const query_params = params + ? params.reduce((acc: { [key: string]: string }, curr: any) => { + acc[curr.key] = curr.value.selected; + return acc; + }, {} as { [key: string]: string }) + : undefined; + + fetchParameters(query_params); + }, [params]); + + const [selectedOptions, setSelectedOptions] = useState([]); + + const handleSelectDropdown = (param: OptionType): void => { + if (selectedOptions.includes(param.value)) { + setSelectedOptions( + selectedOptions.filter(option => option !== param.value) + ); + } else { + setSelectedOptions([...selectedOptions, param.value]); + } + }; + + const renderNoParamsMessage = () => ( +
+

Select a product type to unlock parameters.

+
+ ); + + const renderParameterGroups = () => ( + <> + {params.some(param => param.mandatory) || selectedOptions.length > 0 ? ( + <> + + + + ) : ( +
+

No required parameter for this product type.

+
+ )} + + ); + + return ( +
+ +
+
+ ( + + )} + /> +
+
+ ( + + loadProviderSuggestions(null, inputValue) + } + handleChange={(e: IOptionTypeBase | null) => { + onChange(e?.value); + setProviderValue(e?.value); + }} + /> + )} + /> + ( + + loadProductTypesSuggestions(providerValue, inputValue) + } + handleChange={(e: IOptionTypeBase | null) => { + onChange(e?.value); + + if (e?.value !== productTypeValue) { + setSelectedOptions([]); + setValue('additionnalParameters', [ + { name: '', value: '' } + ]); + } + + setProductTypeValue(e?.value); + if (e?.value === undefined) { + setParams([]); + setOptionalParams([]); + reset({ + provider: formValues.provider, + additionnalParameters: formValues.additionnalParameters + }); + } + }} + /> + )} + /> +
+ +
+
+ + ( + { + setStartDate(d); + onChange(d); + }} + onBlur={onBlur} + selected={value} + dateFormat={'dd/MM/yyyy'} + showMonthDropdown + showYearDropdown + dropdownMode="select" + isClearable + placeholderText="Start" + /> + )} + /> +
+ +
+ + ( + { + setEndDate(d); + onChange(d); + }} + onBlur={onBlur} + selected={value} + dateFormat={'dd/MM/yyyy'} + showMonthDropdown + showYearDropdown + dropdownMode="select" + isClearable + placeholderText="End" + /> + )} + /> +
+
+
+ +
+
+

Parameters

+ +
+
+ {!params || !params.length + ? renderNoParamsMessage() + : renderParameterGroups()} +
+
+ + +
+
+
+ {isLoadingSearch ? ( +
+

Generating

+ +
+ ) : ( + <> +
+ +
+
+ +
+ + )} +
+
+
+
+
+ ); +}; diff --git a/src/formComponent/ParameterGroup.tsx b/src/formComponent/ParameterGroup.tsx index 560ba73..7f227ad 100644 --- a/src/formComponent/ParameterGroup.tsx +++ b/src/formComponent/ParameterGroup.tsx @@ -10,13 +10,25 @@ interface ParameterGroupProps { selectedOptions?: string[]; } -const ParameterGroup: React.FC = ({ params, setParams, mandatory = false, selectedOptions = [] +const ParameterGroup: React.FC = ({ + params, + setParams, + mandatory = false, + selectedOptions = [] }) => { - const { formState: { errors }, setValue, control } = useFormContext(); + const { + formState: { errors }, + setValue, + control + } = useFormContext(); const handleSelectChange = ( key: string, - newValue: number | string | { value: string; label: string } | MultiValue, + newValue: + | number + | string + | { value: string; label: string } + | MultiValue, onChange: (...event: any[]) => void | undefined = undefined ) => { let selectedValue: undefined | number | string | string[]; @@ -50,21 +62,31 @@ const ParameterGroup: React.FC = ({ params, setParams, mand setParams(updatedParams); }; - const getSelectedValue = (type: string, selectedValue: string | string[] | undefined) => { - if (type === 'array' && selectedValue !== undefined || Array.isArray(selectedValue)) { + const getSelectedValue = ( + type: string, + selectedValue: string | string[] | undefined + ) => { + if ( + (type === 'array' && selectedValue !== undefined) || + Array.isArray(selectedValue) + ) { // If it's an array or the type is 'array', return an array of option objects - return (Array.isArray(selectedValue) ? selectedValue : [selectedValue]).map(item => ({ + return ( + Array.isArray(selectedValue) ? selectedValue : [selectedValue] + ).map(item => ({ value: item, - label: item, + label: item })); } // For single value (when it's not an array) return selectedValue - ? [{ - value: selectedValue, - label: selectedValue, - }] + ? [ + { + value: selectedValue, + label: selectedValue + } + ] : []; }; @@ -81,12 +103,14 @@ const ParameterGroup: React.FC = ({ params, setParams, mand rules={{ required: mandatory && enumList.length > 0 }} render={({ field: { onChange } }) => ( = ({ params, setParams, mand borderRadius: '.2rem', border: '1px solid #ccc', padding: '0.25rem 0.5rem', - boxSizing: 'border-box', + boxSizing: 'border-box' }} placeholder={`${title}...`} title={description || undefined} @@ -166,49 +192,65 @@ const ParameterGroup: React.FC = ({ params, setParams, mand /> - ) + ); }; const renderField = (param: Parameter) => { const value = param.value || {}; - const enumList: string[] = value.type === 'array' - ? value.items?.enum || (value.items?.const ? [value.items?.const] : []) - : value?.enum || (value?.const ? [value?.const] : []); + const enumList: string[] = + value.type === 'array' + ? value.items?.enum || (value.items?.const ? [value.items?.const] : []) + : value?.enum || (value?.const ? [value?.const] : []); switch (value.type) { case 'string': case 'integer': - return enumList.length > 0 ? renderSelectField(param, enumList) : renderInputField(param); + return enumList.length > 0 + ? renderSelectField(param, enumList) + : renderInputField(param); case 'array': return renderSelectField(param, enumList); default: console.error(`Unsupported type: ${value.type}`); - return

This parameter is not working. Unsupported type: {value.type}

+ return ( +

+ This parameter is not working. Unsupported type: {value.type} +

+ ); } }; return ( <> - { - params.filter(param => { + {params + .filter(param => { if (mandatory) { return param.mandatory; } else { return selectedOptions.includes(param.key); } - }).map(param => ( + }) + .map(param => (
- {param.key === 'cloudCover' ? renderCloudCoverField(param) : ( + {param.key === 'cloudCover' ? ( + renderCloudCoverField(param) + ) : ( )}
- )) - } + ))} ); }; diff --git a/src/handler.ts b/src/handler.ts index f3519ad..4a5c15b 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -45,7 +45,10 @@ export async function requestAPI( } if (!response.ok) { - throw new ServerConnection.ResponseError(response, JSON.stringify(data.message || data)); + throw new ServerConnection.ResponseError( + response, + JSON.stringify(data.message || data) + ); } return data; diff --git a/src/helpers/fetchQueryables.ts b/src/helpers/fetchQueryables.ts index e08fce4..5609443 100644 --- a/src/helpers/fetchQueryables.ts +++ b/src/helpers/fetchQueryables.ts @@ -1,66 +1,67 @@ -import { requestAPI } from "../handler"; -import { Parameter, Queryables } from "../types"; - -export const fetchQueryables = async ( - provider: string | null, - productType: string, - filterParameters: { [key: string]: any } | undefined -): Promise<{ - properties: Parameter[], - additionalProperties: boolean -}> => { - const params = new URLSearchParams({ productType }); - - if (provider) params.append('provider', provider); - - if (filterParameters) { - Object.entries(filterParameters).forEach(([key, value]) => { - params.append(key, value ?? ""); - }); - } - - const queryables = await requestAPI(`queryables?${params.toString()}`) as Queryables; - - - if (!queryables.properties) { - throw new Error('The response is missing the "properties" attribute.'); - } - - if (typeof queryables.additionalProperties !== 'boolean') { - throw new Error( - 'The response is missing the "additionalProperties" attribute or it is not a boolean.' - ); - } - - // TODO: review the list of exclusion keys - const excludedKeys = new Set([ - "productType", - // geometry related keys because they are handled by the map - "bbox", - "geom", - "geometry", - // temporal keys because they are handled by the date picker - "startTimeFromAscendingNode", - "completionTimeFromAscendingNode", - "start_datetime", - "end_datetime", - "startdate", - "enddate", - "end", - ]); - - const requiredSet = new Set(queryables.required || []); - - const properties = Object.entries(queryables.properties) - .filter(([key]) => !excludedKeys.has(key)) - .map(([key, value]) => ({ - key, - value, - mandatory: requiredSet.has(key), - })); - - return { - properties: properties, - additionalProperties: queryables.additionalProperties, - }; -}; +import { requestAPI } from '../handler'; +import { Parameter, Queryables } from '../types'; + +export const fetchQueryables = async ( + provider: string | null, + productType: string, + filterParameters: { [key: string]: any } | undefined +): Promise<{ + properties: Parameter[]; + additionalProperties: boolean; +}> => { + const params = new URLSearchParams({ productType }); + + if (provider) params.append('provider', provider); + + if (filterParameters) { + Object.entries(filterParameters).forEach(([key, value]) => { + params.append(key, value ?? ''); + }); + } + + const queryables = (await requestAPI( + `queryables?${params.toString()}` + )) as Queryables; + + if (!queryables.properties) { + throw new Error('The response is missing the "properties" attribute.'); + } + + if (typeof queryables.additionalProperties !== 'boolean') { + throw new Error( + 'The response is missing the "additionalProperties" attribute or it is not a boolean.' + ); + } + + // TODO: review the list of exclusion keys + const excludedKeys = new Set([ + 'productType', + // geometry related keys because they are handled by the map + 'bbox', + 'geom', + 'geometry', + // temporal keys because they are handled by the date picker + 'startTimeFromAscendingNode', + 'completionTimeFromAscendingNode', + 'start_datetime', + 'end_datetime', + 'startdate', + 'enddate', + 'end' + ]); + + const requiredSet = new Set(queryables.required || []); + + const properties = Object.entries(queryables.properties) + .filter(([key]) => !excludedKeys.has(key)) + .map(([key, value]) => ({ + key, + value, + mandatory: requiredSet.has(key) + })); + + return { + properties: properties, + additionalProperties: queryables.additionalProperties + }; +}; diff --git a/src/hooks/useFetchData.ts b/src/hooks/useFetchData.ts index 822292e..64d2258 100644 --- a/src/hooks/useFetchData.ts +++ b/src/hooks/useFetchData.ts @@ -2,7 +2,11 @@ import { showErrorMessage } from '@jupyterlab/apputils'; import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import { EODAG_SERVER_ADRESS } from './../config'; -import { IOptionTypeBase, IProduct, IProvider } from './../formComponent/FormComponent'; +import { + IOptionTypeBase, + IProduct, + IProvider +} from './../formComponent/FormComponent'; interface IFetchDataProps { queryParams: string; diff --git a/src/icones.tsx b/src/icones.tsx index 50229a8..67425ff 100644 --- a/src/icones.tsx +++ b/src/icones.tsx @@ -1,4 +1,4 @@ -import React, {CSSProperties} from 'react'; +import React, { CSSProperties } from 'react'; export interface ISVGProps { strokeColor?: string; diff --git a/style/base.css b/style/base.css index 786c24b..01b4c5a 100644 --- a/style/base.css +++ b/style/base.css @@ -96,7 +96,7 @@ align-items: center; } -.jp-EodagWidget-input-wrapper>svg { +.jp-EodagWidget-input-wrapper > svg { display: inline-block; color: var(--gray); } @@ -202,7 +202,9 @@ border-radius: 5px; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-input-wrapper:nth-child(1) { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-input-wrapper:nth-child(1) { margin-right: 0.1rem; } @@ -213,7 +215,9 @@ top: 5px; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-input-wrapper:focus-within { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-input-wrapper:focus-within { border: 1px solid #2196f3; } @@ -258,26 +262,43 @@ cursor: not-allowed; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-optional-button:not(:disabled):hover { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-optional-button:not(:disabled):hover { color: rgb(59, 172, 255); } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters { margin-top: 10px; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters .jp-EodagWidget-input-name { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + .jp-EodagWidget-input-name { margin-bottom: 10px; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters .section { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + .section { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters input { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + input { margin: 0 0.1em; line-height: var(--jp-private-commandpalette-search-height); border: 1px solid var(--jp-border-color0); @@ -286,26 +307,48 @@ width: 40%; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters .jp-EodagWidget-additionnalParameters-label-icon-wrapper { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + .jp-EodagWidget-additionnalParameters-label-icon-wrapper { display: flex; align-items: center; margin-bottom: 10px; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters .jp-EodagWidget-additionnalParameters-label-icon-wrapper a { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + .jp-EodagWidget-additionnalParameters-label-icon-wrapper + a { display: inline-flex; margin-left: 5px; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters .jp-EodagWidget-additionnalParameters-label-icon-wrapper a:hover { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + .jp-EodagWidget-additionnalParameters-label-icon-wrapper + a:hover { color: #677381; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters input:focus-within { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + input:focus-within { border: 1px solid #2196f3; } -.jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field .jp-EodagWidget-additionnalParameters input::placeholder { +.jp-EodagWidget + .jp-EodagWidget-form + .jp-EodagWidget-field + .jp-EodagWidget-additionnalParameters + input::placeholder { padding-left: 0.3rem; } @@ -353,7 +396,7 @@ section button .jp-EodagWidget-additionnalParameters-addbutton svg, } .jp-EodagWidget-select__indicator-separator, -.jp-EodagWidget-select__dropdown-indicator>div>svg { +.jp-EodagWidget-select__dropdown-indicator > div > svg { color: var(--light-gray) !important; } @@ -476,7 +519,9 @@ section button .jp-EodagWidget-additionnalParameters-addbutton svg, width: 50%; } -.jp-EodagWidget-modal .jp-EodagWidget-modal-container .jp-EodagWidget-modal-footer { +.jp-EodagWidget-modal + .jp-EodagWidget-modal-container + .jp-EodagWidget-modal-footer { display: flex; justify-content: center; background-color: var(--jp-brand-color1); @@ -540,7 +585,7 @@ section button .jp-EodagWidget-additionnalParameters-addbutton svg, z-index: 2; } -#jp-left-stack>.lm-Widget { +#jp-left-stack > .lm-Widget { min-width: 315px !important; }