diff --git a/CHANGES.md b/CHANGES.md index dd24ccd..8d32778 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # Release history +## 3.6.0 (2024-03-04) + +- New `reload eodag environment` button [(#139)](https://github.com/CS-SI/eodag-labextension/pull/139) +- Customizable map default settings [(#143)](https://github.com/CS-SI/eodag-labextension/pull/143) +- Handlers patterns fix and code refactoring [(#142)](https://github.com/CS-SI/eodag-labextension/pull/142)[(#144)](https://github.com/CS-SI/eodag-labextension/pull/144) +- Updates dependencies and developement tools versions [(#138)](https://github.com/CS-SI/eodag-labextension/pull/138) + ## 3.5.0 (2024-02-08) - New `provider` filtering [(#127)](https://github.com/CS-SI/eodag-labextension/pull/127) diff --git a/NOTICE b/NOTICE index 568ad1f..35c0d70 100644 --- a/NOTICE +++ b/NOTICE @@ -15,6 +15,7 @@ Open source projects: * Notebook: BSD * Python (interpreter and standard library): PSFL * Setuptools: MIT + * Shapely: BSD * Tornado: Apache v2.0 * Javascript: diff --git a/README.md b/README.md index 56c9cfc..ed55266 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Lab. The package consist of a Python Jupyter notebook REST service consumed by t - [Configuration](#configuration) - [QuickStart](#quickstart) - [Search](#search) + - [Settings](#settings) - [Results overview](#results-overview) - [Apply to the Jupyter notebook](#apply-to-the-jupyter-notebook) - [User manual](#user-manual) @@ -94,9 +95,19 @@ Once search criteria are filled out, click on: - `Generate Code` to automatically generate and insert the corresponding eodag search code bellow the active cell. - `Preview Results` to perform a search in background, display results, and generate search code in a second step. +## Settings + +![reload logo](https://raw.githubusercontent.com/CS-SI/eodag-labextension/develop/notebooks/images/eodag_labext_reload_icon.png) +Click on this icon to reload [EODAG configuration](https://eodag.readthedocs.io/en/stable/getting_started_guide/configure.html) +and take into account your updated credentials or providers settings. + ![settings logo](https://raw.githubusercontent.com/CS-SI/eodag-labextension/develop/notebooks/images/eodag_labext_settings_icon.png) -Click on this icon to open EODAG-Labextension settings. You will be enable to choose whether newly inserted code should -replace existing search code or not. +Click on this icon to open EODAG-Labextension settings. You will be enable to: + +- choose whether newly inserted code should replace existing search code or not; +- configure the default map settings. + +![settings tab](https://raw.githubusercontent.com/CS-SI/eodag-labextension/develop/notebooks/images/eodag_labext_settings_map.png) ### Results overview diff --git a/eodag_labextension/handlers.py b/eodag_labextension/handlers.py index 7f9b454..c0cba53 100644 --- a/eodag_labextension/handlers.py +++ b/eodag_labextension/handlers.py @@ -4,11 +4,14 @@ """Tornado web requests handlers""" +import logging import re import orjson import tornado -from eodag.rest.utils import eodag_api, search_products +from eodag import EODataAccessGateway, SearchResult +from eodag.api.core import DEFAULT_ITEMS_PER_PAGE, DEFAULT_PAGE +from eodag.rest.utils import get_datetime from eodag.utils import parse_qs from eodag.utils.exceptions import ( AuthenticationError, @@ -19,10 +22,15 @@ ) from jupyter_server.base.handlers import APIHandler from jupyter_server.utils import url_path_join +from shapely.geometry import shape + +eodag_api = EODataAccessGateway() + +logger = logging.getLogger("eodag-labextension.handlers") class ProductTypeHandler(APIHandler): - """Product type listing handlerd""" + """Product type listing handler""" @tornado.web.authenticated def get(self): @@ -44,6 +52,15 @@ def get(self): self.write(orjson.dumps(product_types)) +class ReloadHandler(APIHandler): + """EODAG API reload handler""" + + @tornado.web.authenticated + def get(self): + """Get endpoint""" + eodag_api.__init__() + + class ProvidersHandler(APIHandler): """Providers listing handler""" @@ -92,7 +109,7 @@ def get(self): returned_providers += [ p for p in all_providers_list - if first_keyword in p["description"].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 @@ -172,17 +189,33 @@ def post(self, product_type): arguments = orjson.loads(self.request.body) - # move geom to intersects parameter + # geom geom = arguments.pop("geom", None) - if geom: - arguments["intersects"] = geom + try: + arguments["geom"] = shape(geom) if geom else None + except Exception: + self.set_status(400) + self.finish({"error": f"Invalid geometry: {str(geom)}"}) + return + + # dates + try: + arguments["start"], arguments["end"] = get_datetime(arguments) + except ValidationError as e: + self.set_status(400) + self.finish({"error": str(e)}) + return + # provider provider = arguments.pop("provider", None) if provider and provider != "null": arguments["provider"] = provider + # We remove potential None values to use the default values of the search method + arguments = dict((k, v) for k, v in arguments.items() if v is not None) + try: - response = search_products(product_type, arguments, stac_formatted=False) + products, total = eodag_api.search(productType=product_type, **arguments) except ValidationError as e: self.set_status(400) self.finish({"error": e.message}) @@ -204,9 +237,36 @@ def post(self, product_type): self.finish({"error": str(e)}) return + response = SearchResult(products).as_geojson_object() + response.update( + { + "properties": { + "page": int(arguments.get("page", DEFAULT_PAGE)), + "itemsPerPage": DEFAULT_ITEMS_PER_PAGE, + "totalResults": total, + } + } + ) + self.finish(response) +class NotFoundHandler(APIHandler): + """Not found handler""" + + @tornado.web.authenticated + def post(self): + """Post endpoint""" + self.set_status(404) + self.finish({"error": f"No matching handler for {self.request.uri}"}) + + @tornado.web.authenticated + def get(self): + """Get endpoint""" + self.set_status(404) + self.finish({"error": f"No matching handler for {self.request.uri}"}) + + class MethodAndPathMatch(tornado.routing.PathMatches): """Wrapper around `tornado.routing.PathMatches` adding http method matching""" @@ -230,17 +290,21 @@ def setup_handlers(web_app, url_path): base_url = web_app.settings["base_url"] # matching patterns - host_pattern = ".*$" + host_pattern = r".*$" 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") - search_pattern = url_path_join(base_url, url_path, r"(?P[\w-]+)") + search_pattern = url_path_join(base_url, url_path, r"(?P[\w\-\.]+)") + default_pattern = url_path_join(base_url, url_path, r".*") # handlers added for each pattern handlers = [ (product_types_pattern, ProductTypeHandler), + (reload_pattern, ReloadHandler), (providers_pattern, ProvidersHandler), (guess_product_types_pattern, GuessProductTypeHandler), (MethodAndPathMatch("POST", search_pattern), SearchHandler), + (default_pattern, NotFoundHandler), ] web_app.add_handlers(host_pattern, handlers) diff --git a/notebooks/images/eodag_labext_form.png b/notebooks/images/eodag_labext_form.png index e53781c..1eec0a3 100644 Binary files a/notebooks/images/eodag_labext_form.png and b/notebooks/images/eodag_labext_form.png differ diff --git a/notebooks/images/eodag_labext_reload_icon.png b/notebooks/images/eodag_labext_reload_icon.png new file mode 100644 index 0000000..bfd3be1 Binary files /dev/null and b/notebooks/images/eodag_labext_reload_icon.png differ diff --git a/notebooks/images/eodag_labext_settings_map.png b/notebooks/images/eodag_labext_settings_map.png new file mode 100644 index 0000000..4772808 Binary files /dev/null and b/notebooks/images/eodag_labext_settings_map.png differ diff --git a/notebooks/user_manual.ipynb b/notebooks/user_manual.ipynb index d0365f9..5c1889a 100644 --- a/notebooks/user_manual.ipynb +++ b/notebooks/user_manual.ipynb @@ -88,9 +88,25 @@ "\n", "Once search criteria are filled out, click on:\n", "- `Generate Code` to automatically generate and insert the corresponding eodag search code bellow the active cell.\n", - "- `Preview Results` to perform a search in background, display results, and generate search code in a second step.\n", + "- `Preview Results` to perform a search in background, display results, and generate search code in a second step." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Settings\n", + "\n", + "![reload logo](https://raw.githubusercontent.com/CS-SI/eodag-labextension/develop/notebooks/images/eodag_labext_reload_icon.png)\n", + "Click on this icon to reload [EODAG configuration](https://eodag.readthedocs.io/en/stable/getting_started_guide/configure.html) \n", + "and take into account your updated credentials or providers settings.\n", + "\n", + "![settings logo](https://raw.githubusercontent.com/CS-SI/eodag-labextension/develop/notebooks/images/eodag_labext_settings_icon.png)\n", + "Click on this icon to open EODAG-Labextension settings. You will be enable to:\n", + "- choose whether newly inserted code should replace existing search code or not;\n", + "- configure the default map settings.\n", "\n", - "![settings logo](https://raw.githubusercontent.com/CS-SI/eodag-labextension/develop/notebooks/images/eodag_labext_settings_icon.png) Click on this icon to open EODAG-Labextension settings. You will be enable to choose whether newly inserted code should replace existing search code or not." + "![settings tab](https://raw.githubusercontent.com/CS-SI/eodag-labextension/develop/notebooks/images/eodag_labext_settings_map.png)" ] }, { diff --git a/package.json b/package.json index c4a2dc9..7759d51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eodag-labextension", - "version": "3.5.0", + "version": "3.6.0", "description": "Searching remote sensed imagery from various image providers", "keywords": [ "jupyter", @@ -55,6 +55,7 @@ "@jupyterlab/apputils": "^3.4.8", "@jupyterlab/cells": "^3.4.8", "@jupyterlab/notebook": "^3.4.8", + "@jupyterlab/settingregistry": "^3.4.8", "@terraformer/wkt": "^2.1.2", "install": "^0.13.0", "isomorphic-fetch": "^3.0.0", @@ -70,7 +71,7 @@ "react-loader-spinner": "^5.3.4", "react-modal": "3.15.1", "react-select": "5.4.0", - "react-tooltip": "~4.2.21", + "react-tooltip": "~5.26.3", "react-virtualized": "^9.22.3" }, "devDependencies": { diff --git a/schema/plugin.json b/schema/plugin.json index cf96478..816375a 100644 --- a/schema/plugin.json +++ b/schema/plugin.json @@ -9,6 +9,31 @@ "description": "Replace the existing search code containing matching comment with the new code.", "type": "boolean", "default": false + }, + "map": { + "title": "Map settings", + "description": "Settings for editing the center and zoom of the map", + "type": "object", + "properties": { + "lat": { + "title": "Latitude", + "description": "Latitude of the center of the map", + "type": "number", + "default": 46.8 + }, + "lon": { + "title": "Longitude", + "description": "Longitude of the center of the map", + "type": "number", + "default": 1.8 + }, + "zoom": { + "title": "Zoom", + "description": "Zoom level of the map", + "type": "number", + "default": 4 + } + } } }, "additionalProperties": false, diff --git a/setup.py b/setup.py index f49951c..1e926b1 100644 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ "eodag[notebook]>=2.8.0", "orjson", ], - extras_require={"dev": ["black", "pre-commit", "pytest"]}, + extras_require={"dev": ["black", "pre-commit", "pytest", "shapely"]}, zip_safe=False, include_package_data=True, python_requires=">=3.8", diff --git a/src/Autocomplete.tsx b/src/Autocomplete.tsx index 53f8a8a..4733796 100644 --- a/src/Autocomplete.tsx +++ b/src/Autocomplete.tsx @@ -10,7 +10,7 @@ import { SingleValueProps, ValueContainerProps } from 'react-select'; -import ReactTooltip from 'react-tooltip'; +import { Tooltip, PlacesType, VariantType } from 'react-tooltip'; import { OptionTypeBase } from 'react-select/src/types'; import AsyncSelect from 'react-select/async'; @@ -29,19 +29,19 @@ function NoOptionsMessage(props: any) { ); } - +const tooltipDark: VariantType = 'dark'; +const tooltipRight: PlacesType = 'right'; function Option(props: OptionProps) { // Tooltip on the right return ( -
+
{props.children} - +
); } diff --git a/src/FormComponent.tsx b/src/FormComponent.tsx index 573a727..68eefd4 100644 --- a/src/FormComponent.tsx +++ b/src/FormComponent.tsx @@ -19,7 +19,6 @@ import Autocomplete from './Autocomplete'; import SearchService from './SearchService'; import { ChangeEvent } from 'react'; import MapExtentComponent from './MapExtentComponent'; -import _ from 'lodash'; import { IFormInput } from './types'; import { CodiconOpenPreview, @@ -27,10 +26,9 @@ import { CarbonTrashCan, CarbonAddFilled, CarbonCalendarAddAlt, - CarbonSettings, CarbonInformation } from './icones.js'; -import ReactTooltip from 'react-tooltip'; +import { Tooltip, PlacesType, VariantType } from 'react-tooltip'; import { ThreeDots } from 'react-loader-spinner'; import { useFetchProduct, useFetchProvider } from './hooks/useFetchData'; @@ -39,7 +37,8 @@ export interface IProps { saveFormValues: (formValue: IFormInput) => void; handleGenerateQuery: any; isNotebookCreated: any; - commands: any; + reloadIndicator: boolean; + onFetchComplete: () => void; } export interface IOptionTypeBase { @@ -56,12 +55,17 @@ export interface IProvider { description: string; } +const tooltipDark: VariantType = 'dark'; +const tooltipWarning: VariantType = 'warning'; +const tooltipTop: PlacesType = 'top'; + export const FormComponent: FC = ({ handleShowFeature, saveFormValues, handleGenerateQuery, isNotebookCreated, - commands + reloadIndicator, + onFetchComplete }) => { const [productTypes, setProductTypes] = useState(); const [providers, setProviders] = useState(); @@ -74,6 +78,7 @@ export const FormComponent: FC = ({ const [openModal, setOpenModal] = useState(true); const [providerValue, setProviderValue] = useState(null); const [productTypeValue, setProductTypeValue] = useState(null); + const [fetchCount, setFetchCount] = useState(0); const { control, @@ -90,25 +95,42 @@ export const FormComponent: FC = ({ } }); + 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]); + }, [providerValue, reloadIndicator]); useEffect(() => { const fetchData = async () => { const fetchProvider = useFetchProvider(); const providerList = await fetchProvider(productTypeValue); setProviders(providerList); + if (reloadIndicator) { + setFetchCount(fetchCount => fetchCount + 1); + } }; fetchData(); - }, [productTypeValue]); + }, [productTypeValue, reloadIndicator]); + + useEffect(() => { + if (fetchCount === 2) { + onFetchComplete(); + } + }, [fetchCount, onFetchComplete]); const onSubmit: SubmitHandler = data => { if (!isNotebookCreated()) { @@ -145,10 +167,6 @@ export const FormComponent: FC = ({ } }; - const handleOpenSettings = (): void => { - commands.execute('settingeditor:open', { query: 'EODAG' }); - }; - const loadProductTypesSuggestions = useFetchProduct(); const loadProviderSuggestions = useFetchProvider(); @@ -326,8 +344,10 @@ export const FormComponent: FC = ({ } disabled={isLoadingSearch} onClick={() => setOpenModal(true)} - data-for="btn-preview-results" - data-tip="You need to select a product type to preview the results" + data-tooltip-id="btn-preview-results" + data-tooltip-content="You need to select a product type to preview the results" + data-tooltip-variant={tooltipDark} + data-tooltip-place={tooltipTop} >

@@ -336,12 +356,9 @@ export const FormComponent: FC = ({ Results

{!productTypeValue && ( - )} @@ -357,8 +374,10 @@ export const FormComponent: FC = ({ } disabled={isLoadingSearch} onClick={() => setOpenModal(false)} - data-for="btn-generate-value" - data-tip="You need to select a product type to generate the code" + data-tooltip-id="btn-generate-value" + data-tooltip-content="You need to select a product type to generate the code" + data-tooltip-variant={tooltipDark} + data-tooltip-place={tooltipTop} >

@@ -367,12 +386,9 @@ export const FormComponent: FC = ({ Code

{!productTypeValue && ( - )} @@ -382,24 +398,6 @@ export const FormComponent: FC = ({
-
- -
); }; @@ -431,17 +429,13 @@ const Fields = ({ href="https://eodag.readthedocs.io/en/stable/add_provider.html#opensearch-parameters-csv" target="_blank" rel="noopener noreferrer" - data-for="parameters-information" - data-tip="Click to check queryable metadata in parameters documentation" + data-tooltip-id="parameters-information" + data-tooltip-content="Click to check queryable metadata in parameters documentation" + data-tooltip-variant={tooltipDark} + data-tooltip-place={tooltipTop} > - + @@ -463,33 +457,25 @@ const Fields = ({ onClick={() => fields.length === 1 ? clearInput(index) : remove(index) } - data-for="parameters-delete" - data-tip="remove additionnal parameter" + data-tooltip-id="parameters-delete" + data-tooltip-content="remove additionnal parameter" + data-tooltip-variant={tooltipWarning} + data-tooltip-place={tooltipTop} > - + diff --git a/src/MapExtentComponent.tsx b/src/MapExtentComponent.tsx index eae00e9..ffa4d84 100644 --- a/src/MapExtentComponent.tsx +++ b/src/MapExtentComponent.tsx @@ -10,6 +10,7 @@ import { throttle } from 'lodash'; import { EODAG_TILE_URL, EODAG_TILE_COPYRIGHT } from './config'; import { IGeometry } from './types'; import { LeafletMouseEvent } from 'leaflet'; +import { EodagWidget } from './widget'; export interface IProps { onChange: (value: IGeometry) => void; @@ -31,6 +32,7 @@ export default class MapExtentComponent extends React.Component< * Leaflet map object */ map: any; + eodagWidget: EodagWidget; EditOptions = { polyline: false, @@ -47,6 +49,7 @@ export default class MapExtentComponent extends React.Component< zoom: 4, geometry: props.geometry }; + this.handleMapSettingsChange = this.handleMapSettingsChange.bind(this); } /** @@ -66,6 +69,22 @@ export default class MapExtentComponent extends React.Component< } this.invalidateMapSize(); }, 100); + this.eodagWidget = EodagWidget.getCurrentInstance(); + this.eodagWidget.mapSettingsChanged.connect(this.handleMapSettingsChange); + } + + componentWillUnmount() { + this.eodagWidget.mapSettingsChanged.disconnect( + this.handleMapSettingsChange + ); + } + + handleMapSettingsChange( + sender: EodagWidget, + settings: { lat: number; lon: number; zoom: number } + ) { + const { lat, lon, zoom } = settings; + this.setState({ lat, lon, zoom }); } /** diff --git a/src/ModalComponent.tsx b/src/ModalComponent.tsx index d6e92c5..4dbb5a0 100644 --- a/src/ModalComponent.tsx +++ b/src/ModalComponent.tsx @@ -12,7 +12,7 @@ import BrowseComponent from './BrowseComponent'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'; import DescriptionProductComponent from './DescriptionProductComponent'; -import ReactTooltip from 'react-tooltip'; +import { Tooltip, PlacesType, VariantType } from 'react-tooltip'; const customStyles: Styles = { content: { @@ -51,6 +51,8 @@ export interface IState { function Transition(props: any) { return
; } +const tooltipDark: VariantType = 'dark'; +const tooltipBottom: PlacesType = 'bottom'; // Override modal's default style Modal.defaultStyles.overlay.zIndex = 4; @@ -180,18 +182,14 @@ export default class ModalComponent extends React.Component {
{isRetrievingMoreFeature() ? (
- +
) : null} {get(features, 'features', []).length} results (total:{' '} diff --git a/src/browser.tsx b/src/browser.tsx index 792bb05..22d0b3f 100644 --- a/src/browser.tsx +++ b/src/browser.tsx @@ -21,6 +21,9 @@ import { IFormInput } from './types'; import { ServerConnection } from '@jupyterlab/services'; import { EODAG_SETTINGS_ADDRESS } from './config'; import { URLExt } from '@jupyterlab/coreutils'; +import { useFetchUserSettings } from './hooks/useFetchData'; +import { CarbonSettings, IcBaselineRefresh } from './icones'; +import { Tooltip, PlacesType, VariantType } from 'react-tooltip'; export interface IProps { tracker: INotebookTracker; @@ -33,8 +36,13 @@ export interface IState { searching: any; formValues: IFormInput; replaceCellIndex: number; + isLoading: boolean; + reloadIndicator: boolean; } +const tooltipDark: VariantType = 'dark'; +const tooltipBottom: PlacesType = 'bottom'; + export class EodagBrowser extends React.Component { constructor(props: IProps) { super(props); @@ -43,8 +51,11 @@ export class EodagBrowser extends React.Component { openDialog: false, searching: false, formValues: undefined, - replaceCellIndex: undefined + replaceCellIndex: undefined, + isLoading: false, + reloadIndicator: false }; + this.reloadUserSettings = this.reloadUserSettings.bind(this); } handleCurrentWidgetError = () => { @@ -221,11 +232,67 @@ export class EodagBrowser extends React.Component { }); }; + handleOpenSettings = (): void => { + this.props.commands.execute('settingeditor:open', { query: 'EODAG' }); + }; + + updateLoadingState = () => { + this.setState(prevState => ({ + isLoading: !prevState.isLoading, + reloadIndicator: !prevState.reloadIndicator + })); + }; + + reloadUserSettings = () => { + useFetchUserSettings(); + this.updateLoadingState(); + }; + + resetIsLoading = () => { + this.updateLoadingState(); + }; + render() { const { openDialog, features } = this.state; return ( -
-
Products search
+
+
+
Products search
+
+
+ +
+
+ +
+
+
{ this.setState({ formValues }) } handleGenerateQuery={this.handleGenerateQuery} - commands={this.props.commands} + reloadIndicator={this.state.reloadIndicator} + onFetchComplete={this.resetIsLoading} /> { return fetchProvider; }; -export { fetchData, useFetchProduct, useFetchProvider }; +const useFetchUserSettings = async () => { + const serverSettings = ServerConnection.makeSettings(); + const eodagServer = URLExt.join( + serverSettings.baseUrl, + `${EODAG_SERVER_ADRESS}` + ); + + try { + const response = await fetch(URLExt.join(eodagServer, 'reload'), { + credentials: 'same-origin' + }); + if (response.status >= 400) { + throw new Error('Bad response from server'); + } + } catch (error) { + showErrorMessage( + `Unable to contact the EODAG server. Are you sure the address is ${eodagServer}/ ?`, + {} + ); + } +}; + +export { fetchData, useFetchProduct, useFetchProvider, useFetchUserSettings }; diff --git a/src/icones.tsx b/src/icones.tsx index eb02496..3550f88 100644 --- a/src/icones.tsx +++ b/src/icones.tsx @@ -118,3 +118,20 @@ export const CarbonSettings = (props: ISVGProps) => { ); }; + +export const IcBaselineRefresh = (props: ISVGProps) => { + return ( + + + + ); +}; diff --git a/src/index.ts b/src/index.ts index ad8b99d..53144aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,37 +9,67 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { INotebookTracker } from '@jupyterlab/notebook'; +import { ISettingRegistry } from '@jupyterlab/settingregistry'; import '../style/index.css'; import 'leaflet/dist/leaflet.css'; import 'leaflet-draw/dist/leaflet.draw.css'; import { EodagWidget } from './widget'; +import { MapSettings } from './types'; const NAMESPACE = 'eodag-widget'; +const PLUGIN_ID = 'eodag-labextension:plugin'; /** * Initialization data for the eodag-labextension extension. */ const extension: JupyterFrontEndPlugin = { - id: 'eodag-labextension:plugin', + id: PLUGIN_ID, autoStart: true, - requires: [INotebookTracker, ILayoutRestorer], + requires: [INotebookTracker, ILayoutRestorer, ISettingRegistry], activate: activate }; +/** + * Load the map settings for the extension. + * Each time the settings change, the map is updated. + */ +function loadSetting(setting: ISettingRegistry.ISettings): void { + const eodagWidget = EodagWidget.getCurrentInstance(); + const mapSettings = setting.get('map').composite as MapSettings; + const defaultMapSettings = setting.default('map') as MapSettings; + + if (eodagWidget) { + const { lat, lon, zoom } = mapSettings || defaultMapSettings; + eodagWidget.updateMapSettings(lat, lon, zoom); + } +} + /** * Activate the extension. */ function activate( app: JupyterFrontEnd, tracker: INotebookTracker, - restorer: ILayoutRestorer + restorer: ILayoutRestorer, + settings: ISettingRegistry ) { const { commands } = app; const eodagBrowser = new EodagWidget(tracker, commands); restorer.add(eodagBrowser, NAMESPACE); app.shell.add(eodagBrowser, 'left', { rank: 700 }); + + Promise.all([app.restored, settings.load(PLUGIN_ID)]) + .then(([, setting]) => { + loadSetting(setting); + setting.changed.connect(loadSetting); + }) + .catch(error => { + console.error( + `Something went wrong when reading the settings.\n${error}` + ); + }); } export default extension; diff --git a/src/types.ts b/src/types.ts index cadc6d4..4742bd3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,3 +37,5 @@ export interface IFeaturePropertie { key: string; value: any; } + +export type MapSettings = { lat: number; lon: number; zoom: number }; diff --git a/src/widget.tsx b/src/widget.tsx index afb2d64..9941353 100644 --- a/src/widget.tsx +++ b/src/widget.tsx @@ -3,13 +3,14 @@ * All rights reserved */ +import { INotebookTracker } from '@jupyterlab/notebook'; +import { Widget } from '@lumino/widgets'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { Widget } from '@lumino/widgets'; -import { INotebookTracker } from '@jupyterlab/notebook'; import { EodagBrowser } from './browser'; import { LabIcon } from '@jupyterlab/ui-components'; +import { Signal, ISignal } from '@lumino/signaling'; import iconSvgStr from '../style/icon.svg'; export const logoIcon = new LabIcon({ @@ -20,11 +21,20 @@ export const logoIcon = new LabIcon({ export class EodagWidget extends Widget { tracker: INotebookTracker; commands: any; + private static _instance: EodagWidget | null = null; + _mapSettingsChanged = new Signal< + this, + { lat: number; lon: number; zoom: number } + >(this); + /** * Construct a new EodagBrowser widget. */ constructor(tracker: INotebookTracker, commands: any) { super(); + if (!EodagWidget._instance) { + EodagWidget._instance = this; + } this.title.caption = 'EODAG'; this.title.icon = logoIcon; this.id = 'eodag-widget'; @@ -34,6 +44,21 @@ export class EodagWidget extends Widget { this.update(); } + static getCurrentInstance(): EodagWidget | null { + return EodagWidget._instance; + } + + get mapSettingsChanged(): ISignal< + this, + { lat: number; lon: number; zoom: number } + > { + return this._mapSettingsChanged; + } + + updateMapSettings(lat: number, lon: number, zoom: number): void { + this._mapSettingsChanged.emit({ lat, lon, zoom }); + } + onUpdateRequest() { ReactDOM.unmountComponentAtNode(this.node); ReactDOM.render( diff --git a/style/base.css b/style/base.css index e20516f..6186ffd 100644 --- a/style/base.css +++ b/style/base.css @@ -17,7 +17,16 @@ overflow: auto; padding: 5px; } +.jp-EodagWidget-products-search { + overflow: auto; + height: 100%; +} +.jp-EodagWidget-header-wrapper { + display: flex; + justify-content: space-between; + align-items: center; +} .jp-EodagWidget-header { border-bottom: var(--jp-border-width) solid var(--jp-border-color2); flex: 0 0 auto; @@ -29,9 +38,21 @@ text-transform: uppercase; } +.jp-EodagWidget-settings-wrapper { + display: flex; + flex-direction: row; +} + +.jp-EodagWidget-settings-wrapper button { + padding: 1px 4px; +} + +.jp-EodagWidget-settings-wrapper button:focus-visible { + outline: none; +} + .jp-EodagWidget-wrapper { display: flex; - height: 100%; flex-direction: column; justify-content: space-between; } @@ -79,7 +100,7 @@ .jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-buttons { display: flex; justify-content: center; - margin-top: 50px; + margin-top: 1em; } .jp-EodagWidget-buttons-button__disabled { @@ -135,9 +156,8 @@ } .jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field { padding: 0 10px; - max-width: 450px; - margin: 10px auto; + margin: auto; } .jp-EodagWidget .jp-EodagWidget-form .jp-EodagWidget-field fieldset { margin-top: 1.5em; @@ -560,3 +580,16 @@ section button .jp-EodagWidget-additionnalParameters-addbutton svg, label.jp-EodagWidget-input-name:nth-child(3) { padding: 0 10px; } + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.spin-icon { + animation: spin 1s linear infinite; +} diff --git a/tests/test_handlers.py b/tests/test_handlers.py index 3b25bd7..17bd882 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -6,7 +6,10 @@ import re from unittest import mock +from eodag import SearchResult +from eodag.api.core import DEFAULT_ITEMS_PER_PAGE from notebook.notebookapp import NotebookApp +from shapely.geometry import shape from tornado.testing import AsyncHTTPTestCase from tornado.web import authenticated @@ -46,20 +49,32 @@ def get_app(self): return app.web_app + @mock.patch.object(APIHandler, "check_xsrf_cookie", return_value=MockUser()) @mock.patch.object(APIHandler, "get_current_user", return_value=MockUser()) @mock.patch.object(authenticated, "__call__", return_value=lambda x: x) - def fetch_results(self, url, mock_auth, mock_user): + def fetch_results(self, url, mock_auth, mock_user, mock_xsrf, **kwargs): """Check that request status is 200 and return the json result as dict""" - response = self.fetch(url) + response = self.fetch(url, **kwargs) self.assertEqual(response.code, 200) return json.loads(response.body.decode("utf-8")) + @mock.patch.object(APIHandler, "check_xsrf_cookie", return_value=MockUser()) @mock.patch.object(APIHandler, "get_current_user", return_value=MockUser()) @mock.patch.object(authenticated, "__call__", return_value=lambda x: x) - def fetch_results_err400(self, url, mock_auth, mock_user): + def fetch_results_error( + self, + url, + error_code=None, + mock_auth=None, + mock_user=None, + mock_xsrf=None, + **kwargs, + ): """Check that request returns a 400 error""" - response = self.fetch(url) - self.assertEqual(response.code, 400) + response = self.fetch(url, **kwargs) + self.assertNotEqual(response.code, 200) + if error_code: + self.assertEqual(response.code, error_code) return json.loads(response.body.decode("utf-8")) def test_product_types(self): @@ -73,7 +88,7 @@ def test_product_types(self): self.assertLess(len(less_results), len(results)) # unknown provider - self.fetch_results_err400("/eodag/product-types?provider=foo") + self.fetch_results_error("/eodag/product-types?provider=foo", 400) def test_providers(self): # all providers @@ -126,4 +141,76 @@ def test_guess_product_types(self): self.assertLess(len(other_results), len(all_results)) self.assertTrue(other_results[0]["ID"].lower().startswith("cop")) - self.fetch_results_err400("/eodag/guess-product-type?provider=foo") + self.fetch_results_error("/eodag/guess-product-type?provider=foo", 400) + + def test_get_not_found(self): + self.fetch_results_error("/eodag/foo", 404) + + def test_post_not_found(self): + self.fetch_results_error("/eodag/foo/bar", 404, method="POST", body=json.dumps({})) + + @mock.patch("eodag.api.core.EODataAccessGateway.search", autospec=True, return_value=(SearchResult([]), 0)) + def test_search(self, mock_search): + geom_dict = { + "type": "Polygon", + "coordinates": [[[0, 2], [0, 3], [1, 3], [1, 2], [0, 2]]], + } + # full example + result = self.fetch_results( + "/eodag/S2_MSI_L1C", + method="POST", + body=json.dumps( + { + "dtstart": "2024-01-01", + "dtend": "2024-01-02", + "page": 1, + "geom": geom_dict, + "cloudCover": 50, + "foo": "bar", + "provider": "cop_dataspace", + } + ), + ) + mock_search.assert_called_once_with( + mock.ANY, + productType="S2_MSI_L1C", + start="2024-01-01T00:00:00", + end="2024-01-02T00:00:00", + geom=shape(geom_dict), + page=1, + cloudCover=50, + foo="bar", + provider="cop_dataspace", + ) + self.assertDictEqual( + result, + { + "type": "FeatureCollection", + "features": [], + "properties": { + "page": 1, + "itemsPerPage": DEFAULT_ITEMS_PER_PAGE, + "totalResults": 0, + }, + }, + ) + + # minimal example + mock_search.reset_mock() + result = self.fetch_results( + "/eodag/S2_MSI_L1C", + method="POST", + body=json.dumps({}), + ) + mock_search.assert_called_once_with( + mock.ANY, + productType="S2_MSI_L1C", + ) + + # date error + mock_search.reset_mock() + self.fetch_results_error("/eodag/S2_MSI_L1C", 400, method="POST", body=json.dumps({"dtstart": "2024-015-01"})) + + # geom error + mock_search.reset_mock() + self.fetch_results_error("/eodag/S2_MSI_L1C", 400, method="POST", body=json.dumps({"geom": {"foo": "bar"}})) diff --git a/yarn.lock b/yarn.lock index 897e380..c570f21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -339,6 +339,26 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.0.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" + integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== + dependencies: + "@floating-ui/utils" "^0.2.1" + +"@floating-ui/dom@^1.6.1": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" + integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + "@fortawesome/fontawesome-common-types@6.2.0": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz#76467a94aa888aeb22aafa43eb6ff889df3a5a7f" @@ -2464,6 +2484,11 @@ classnames@*, classnames@^2.2, classnames@^2.2.6: resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== +classnames@^2.3.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b" + integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== + clean-stack@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" @@ -3140,13 +3165,14 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: - version "0.10.62" - resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.62.tgz#5e6adc19a6da524bf3d1e02bbc8960e5eb49a9a5" - integrity sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA== +es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50, es5-ext@^0.10.53, es5-ext@^0.10.62, es5-ext@~0.10.14, es5-ext@~0.10.2, es5-ext@~0.10.46: + version "0.10.64" + resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.64.tgz#12e4ffb48f1ba2ea777f1fcdd1918ef73ea21714" + integrity sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg== dependencies: es6-iterator "^2.0.3" es6-symbol "^3.1.3" + esniff "^2.0.1" next-tick "^1.1.0" es6-iterator@^2.0.3: @@ -3306,6 +3332,16 @@ eslint@^8.23.1: strip-json-comments "^3.1.0" text-table "^0.2.0" +esniff@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/esniff/-/esniff-2.0.1.tgz#a4d4b43a5c71c7ec51c51098c1d8a29081f9b308" + integrity sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg== + dependencies: + d "^1.0.1" + es5-ext "^0.10.62" + event-emitter "^0.3.5" + type "^2.7.2" + espree@^9.4.0: version "9.4.0" resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" @@ -5788,13 +5824,13 @@ react-select@5.4.0: prop-types "^15.6.0" react-transition-group "^4.3.0" -react-tooltip@~4.2.21: - version "4.2.21" - resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-4.2.21.tgz#840123ed86cf33d50ddde8ec8813b2960bfded7f" - integrity sha512-zSLprMymBDowknr0KVDiJ05IjZn9mQhhg4PRsqln0OZtURAJ1snt1xi5daZfagsh6vfsziZrc9pErPTDY1ACig== +react-tooltip@~5.26.3: + version "5.26.3" + resolved "https://registry.yarnpkg.com/react-tooltip/-/react-tooltip-5.26.3.tgz#bcb9a53e15bdbf9ae007ddf8bf413a317a637054" + integrity sha512-MpYAws8CEHUd/RC4GaDCdoceph/T4KHM5vS5Dbk8FOmLMvvIht2ymP2htWdrke7K6lqPO8rz8+bnwWUIXeDlzg== dependencies: - prop-types "^15.7.2" - uuid "^7.0.3" + "@floating-ui/dom" "^1.6.1" + classnames "^2.3.0" react-transition-group@^2.9.0: version "2.9.0" @@ -6804,11 +6840,6 @@ uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== -uuid@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" - integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== - validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"