From d419fbe7df6a96b02afb8853bbf10275eab6ede4 Mon Sep 17 00:00:00 2001 From: sufyanAbbasi Date: Mon, 16 Dec 2024 16:07:12 -0800 Subject: [PATCH] Implement SearchBar using anywidget and LitElement (#2182) * Implement LayerManager using LitElement + anywidget * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update static files * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use non-minified JS files to work around property renaming issue * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Set up tests for layer_manager_row * Set up layer_manager_row test * Implement LayerManager using LitElement + anywidget * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update static files * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use non-minified JS files to work around property renaming issue * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Clean up setuptools references in pyproject.toml * Clean up setuptools references in pyproject.toml * Fix dark mode and drop shadow issues in Colab * Remove common.css, load fonts using JS instead. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Rebuild * Remove extraneous files * Address comments from initial review * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Ignore static files * Fix TS errors * Convert tsconfig.json to spaces and export model interfaces * Add TS tests for anywidgets * Add a tab panel component * clean up styles * Add css classes for better testability * Add better css classes (p2), build before test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add a rough basemap-selector widget * Implement Inspector using anywidget and LitElement * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add a tab panel and rough toolbar * Add tests for basemap selector widget * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Increase margin to 4px * Add working, rough toolbar (broken layer_manager) * Add None check to plot tool * Return empty list for extra tools in core * Unbreak layer_manager * Make sure rerendering preserves tab index * Use primary styling to match old style * Support dark mode better * Fix toolbar unit tests * Address review comments. * Add type annotation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address reviewer comments * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Extend LitWidget for ToolbarItem too * Remove scratch file * Fix core tests * Implement search bar as anywidget (not working yet) * Adjust margins * Search bar MVP * tab-clicked --> tab-changed * tab-clicked --> tab-changed * Move routines to helper methods * Change info icon to point scan (cross-hair) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add reset button to location search * Undo icon change * Remove scratch file * Add searchbar ts tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add better comment about tooltip property * Change some icons to match mocks * Address review comments * Search on enter --------- Co-authored-by: Nathaniel Schmitz Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Qiusheng Wu --- geemap/geemap.py | 2 +- geemap/toolbar.py | 601 ++++++++++++++++++++------------------- js/ipywidgets_styles.ts | 33 +++ js/search_bar.ts | 396 ++++++++++++++++++++++++++ js/tab_panel.ts | 5 +- tests/search_bar.spec.ts | 128 +++++++++ 6 files changed, 876 insertions(+), 289 deletions(-) create mode 100644 js/search_bar.ts create mode 100644 tests/search_bar.spec.ts diff --git a/geemap/geemap.py b/geemap/geemap.py index 047d37f4f4..c4949c6e49 100644 --- a/geemap/geemap.py +++ b/geemap/geemap.py @@ -349,7 +349,7 @@ def add( obj = backward_compatibilities.get(obj, obj) if obj == "data_ctrl": - data_widget = toolbar.SearchDataGUI(self) + data_widget = toolbar.SearchBar(self) data_control = ipyleaflet.WidgetControl( widget=data_widget, position=position ) diff --git a/geemap/toolbar.py b/geemap/toolbar.py index 807f166f25..3f561c1bb2 100644 --- a/geemap/toolbar.py +++ b/geemap/toolbar.py @@ -25,6 +25,7 @@ from typing import Any, Callable, Optional from .common import * +from .conversion import js_snippet_to_py from .timelapse import * from . import map_widgets @@ -761,316 +762,344 @@ def close_click(_): @map_widgets.Theme.apply -class SearchDataGUI(widgets.HBox): - def __init__(self, m, **kwargs): - # Adds search button and search box +class SearchBar(anywidget.AnyWidget): + _esm = pathlib.Path(__file__).parent / "static" / "search_bar.js" - from .conversion import js_snippet_to_py - - m.search_locations = None - m.search_loc_marker = None - m.search_loc_geom = None - m.search_datasets = None - - search_button = widgets.ToggleButton( - value=False, - tooltip="Search location/data", - icon="globe", - layout=widgets.Layout( - width="28px", height="28px", padding="0px 0px 0px 4px" - ), - ) + # Whether the toolbar is expanded. + expanded = traitlets.Bool(False).tag(sync=True) - search_type = widgets.ToggleButtons( - options=["name/address", "lat-lon", "data"], - tooltips=[ - "Search by place name or address", - "Search by lat-lon coordinates", - "Search Earth Engine data catalog", - ], - ) - search_type.style.button_width = "110px" + # The currently selected tab. + tab_index = traitlets.Int(0).tag(sync=True) - search_box = widgets.Text( - placeholder="Search by place name or address", - tooltip="Search location", - layout=widgets.Layout(width="340px"), + # The stringified JSON for the name/address search. + name_address_model = traitlets.Unicode( + json.dumps( + { + "search": "", + "results": [], + "selected": "", + "additional_html": "", + } ) - - search_output = widgets.Output( - layout={ - "max_width": "340px", - "max_height": "350px", - "overflow": "scroll", + ).tag(sync=True) + + # The stringified JSON for the lat/lon search. + lat_lon_model = traitlets.Unicode( + json.dumps( + { + "search": "", + "results": [], + "selected": "", + "additional_html": "", } ) - - search_results = widgets.RadioButtons() - - assets_dropdown = widgets.Dropdown( - options=[], - layout=widgets.Layout(min_width="279px", max_width="279px"), + ).tag(sync=True) + + # The stringified JSON for the dataset search. + dataset_model = traitlets.Unicode( + json.dumps( + { + "search": "", + "results": [], + "selected": "", + "additional_html": "", + } ) + ).tag(sync=True) - import_btn = widgets.Button( - description="import", - button_style="primary", - tooltip="Click to import the selected asset", - layout=widgets.Layout(min_width="57px", max_width="57px"), + def __init__(self, host_map, **kwargs): + super().__init__() + self.host_map = host_map + self.host_map.search_locations = None + self.host_map.search_loc_marker = None + self.host_map.search_loc_geom = None + self.host_map.search_datasets = None + + self.on_msg(self.handle_message_event) + + def handle_message_event( + self, widget: widgets.Widget, content: Dict[str, Any], buffers: List[Any] + ) -> None: + del widget, buffers # Unused + if content.get("type") == "click": + msg_id = content.get("id", "") + if msg_id == "import": + self.import_button_clicked() + + @traitlets.observe("tab_index") + def _observe_tab_index(self, change: Dict[str, Any]): + tab_index = change.get("new") + selected_name_address = json.loads(self.name_address_model).get( + "selected", None ) - - def get_ee_example(asset_id): - try: - import pkg_resources - - pkg_dir = os.path.dirname( - pkg_resources.resource_filename("geemap", "geemap.py") - ) - with open( - os.path.join(pkg_dir, "data/gee_f.json"), encoding="utf-8" - ) as f: - functions = json.load(f) - details = [ - dataset["code"] - for x in functions["examples"] - for dataset in x["contents"] - if x["name"] == "Datasets" - if dataset["name"] == asset_id.replace("/", "_") - ] - - return js_snippet_to_py( - details[0], - add_new_cell=False, - import_ee=False, - import_geemap=False, - show_map=False, - Map=m._var_name, + lat_lon_search = json.loads(self.lat_lon_model).get("search", None) + if tab_index == 0 and selected_name_address: + self._set_selected_name_address(selected_name_address) + elif tab_index == 1 and lat_lon_search: + self._search_lat_lon(lat_lon_search) + + @traitlets.observe("name_address_model") + def _observe_name_address_model(self, change: Dict[str, Any]) -> None: + old = json.loads(change.get("old")) + new = json.loads(change.get("new")) + if new["search"] != old["search"]: + if new["search"]: + self._search_name_address(new["search"]) + else: + self.name_address_model = json.dumps( + { + "search": "", + "results": [], + "selected": "", + "additional_html": "", + } ) - - except Exception as e: - pass - return - - def import_btn_clicked(b): - if assets_dropdown.value is not None: - datasets = m.search_datasets - dataset = datasets[assets_dropdown.index] - id_ = dataset["id"] - code = get_ee_example(id_) - - if not code: - dataset_uid = "dataset_" + random_string(string_length=3) - translate = { - "image_collection": "ImageCollection", - "image": "Image", - "table": "FeatureCollection", - "table_collection": "FeatureCollection", + marker = self.host_map.search_loc_marker + self.host_map.search_loc_marker = None + self.host_map.remove(marker) + + elif new["selected"] and new["selected"] != old["selected"]: + self._set_selected_name_address(new["selected"]) + + @traitlets.observe("lat_lon_model") + def _observe_lat_lon_model(self, change: Dict[str, Any]) -> None: + old = json.loads(change.get("old")) + new = json.loads(change.get("new")) + if new["search"] != old["search"]: + if new["search"]: + self._search_lat_lon(new["search"]) + else: + self.lat_lon_model = json.dumps( + { + "search": "", + "results": [], + "selected": "", + "additional_html": "", } - datatype = translate[dataset["type"]] - id_ = dataset["id"] - line1 = "{} = ee.{}('{}')".format(dataset_uid, datatype, id_) - action = { - "image_collection": f"\n{m._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", - "image": f"\n{m._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", - "table": f"\n{m._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", - "table_collection": f"\n{m._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", + ) + marker = self.host_map.search_loc_marker + self.host_map.search_loc_marker = None + self.host_map.remove(marker) + + if new["selected"] and new["selected"] != old["selected"]: + pass + + @traitlets.observe("dataset_model") + def _observe_dataset_model(self, change: Dict[str, Any]) -> None: + old = json.loads(change.get("old")) + new = json.loads(change.get("new")) + if new["search"] != old["search"]: + if new["search"]: + self._search_dataset(new["search"]) + else: + self.dataset_model = json.dumps( + { + "search": "", + "results": [], + "selected": "", + "additional_html": "", } - line2 = action[dataset["type"]] - code = [line1, line2] - - contents = "".join(code).strip() - # create_code_cell(contents) - - try: - import pyperclip - - pyperclip.copy(str(contents)) - except Exception as e: - pass - - with search_output: - search_output.clear_output() - print( - "# The code has been copied to the clipboard. \n# Press Ctrl+V in a new cell to paste it.\n" - ) - print(contents) - - import_btn.on_click(import_btn_clicked) - - html_widget = widgets.HTML() - - def dropdown_change(change): - dropdown_index = assets_dropdown.index - if dropdown_index is not None and dropdown_index >= 0: - search_output.clear_output() - search_output.append_stdout("Loading ...") - datasets = m.search_datasets - dataset = datasets[dropdown_index] - dataset_html = ee_data_html(dataset) - html_widget.value = dataset_html - search_output.clear_output() - with search_output: - display(html_widget) - # search_output.append_display_data(html_widget) - - assets_dropdown.observe(dropdown_change, names="value") - - assets_combo = widgets.HBox() - assets_combo.children = [import_btn, assets_dropdown] - - def search_result_change(change): - result_index = search_results.index - locations = m.search_locations - location = locations[result_index] - latlon = (location.lat, location.lng) - m.search_loc_geom = ee.Geometry.Point(location.lng, location.lat) - marker = m.search_loc_marker + ) + elif new["selected"] and new["selected"] != old["selected"]: + self._select_dataset(new["selected"]) + + def _search_name_address(self, address): + name_address_model = json.loads(self.name_address_model) + geoloc_results = geocode(address) + self.host_map.search_locations = geoloc_results + if geoloc_results is not None and len(geoloc_results) > 0: + name_address_model["results"] = [x.address for x in geoloc_results] + self.name_address_model = json.dumps(name_address_model) + else: + name_address_model["results"] = [] + name_address_model["selected"] = "" + name_address_model["additional_html"] = "No results could be found." + self.name_address_model = json.dumps(name_address_model) + + def _set_selected_name_address(self, address): + locations = self.host_map.search_locations + location = None + for l in locations: + if l.address == address: + location = l + if not location: + return + latlon = (location.lat, location.lng) + self.host_map.search_loc_geom = ee.Geometry.Point(location.lng, location.lat) + if self.host_map.search_loc_marker is None: + marker = ipyleaflet.Marker( + location=latlon, + draggable=False, + name="Search location", + ) + self.host_map.search_loc_marker = marker + self.host_map.add(marker) + self.host_map.center = latlon + else: + marker = self.host_map.search_loc_marker marker.location = latlon - m.center = latlon - - search_results.observe(search_result_change, names="value") - - def search_btn_click(change): - if change["new"]: - self.children = [search_button, search_result_widget] - search_type.value = "name/address" + self.host_map.center = latlon + + def _search_lat_lon(self, lat_lon): + lat_lon_model = json.loads(self.lat_lon_model) + if latlon := latlon_from_text(lat_lon): + geoloc_results = geocode(lat_lon, reverse=True) + if geoloc_results is not None and len(geoloc_results) > 0: + top_loc = geoloc_results[0] + latlon = (top_loc.lat, top_loc.lng) + lat_lon_model["results"] = [x.address for x in geoloc_results] + lat_lon_model["selected"] = lat_lon_model["results"][0] + lat_lon_model["additional_html"] = "" + self.lat_lon_model = json.dumps(lat_lon_model) else: - self.children = [search_button] - search_result_widget.children = [search_type, search_box] - - search_button.observe(search_btn_click, "value") - - def search_type_changed(change): - search_box.value = "" - search_output.clear_output() - if change["new"] == "data": - search_box.placeholder = ( - "Search GEE data catalog by keywords, e.g., elevation" + lat_lon_model["results"] = [] + lat_lon_model["selected"] = "" + lat_lon_model["additional_html"] = "No results could be found." + self.lat_lon_model = json.dumps(lat_lon_model) + self.host_map.search_loc_geom = ee.Geometry.Point(latlon[1], latlon[0]) + if self.host_map.search_loc_marker is None: + marker = ipyleaflet.Marker( + location=latlon, + draggable=False, + name="Search location", ) - search_result_widget.children = [ - search_type, - search_box, - assets_combo, - search_output, - ] - elif change["new"] == "lat-lon": - search_box.placeholder = "Search by lat-lon, e.g., 40, -100" - assets_dropdown.options = [] - search_result_widget.children = [ - search_type, - search_box, - search_output, - ] - elif change["new"] == "name/address": - search_box.placeholder = "Search by place name or address, e.g., Paris" - assets_dropdown.options = [] - search_result_widget.children = [ - search_type, - search_box, - search_output, - ] + self.host_map.search_loc_marker = marker + self.host_map.add(marker) + self.host_map.center = latlon + else: + marker = self.host_map.search_loc_marker + marker.location = latlon + self.host_map.center = latlon + else: + lat_lon_model["results"] = [] + lat_lon_model["selected"] = "" + no_results = ( + """""" + "The lat-lon coordinates should be numbers only and" + "
" + "separated by comma or space, such as 40.2, -100.3" + "
" + ) + lat_lon_model["additional_html"] = no_results + self.lat_lon_model = json.dumps(lat_lon_model) + + def _search_dataset(self, dataset_search): + dataset_model = json.loads(self.dataset_model) + dataset_model["additional_html"] = "Searching..." + self.dataset_model = json.dumps(dataset_model) + self.host_map.default_style = {"cursor": "wait"} + ee_assets = search_ee_data(dataset_search, source="all") + self.host_map.search_datasets = ee_assets + asset_titles = [x["title"] for x in ee_assets] + dataset_model["results"] = asset_titles + dataset_model["selected"] = asset_titles[0] if asset_titles else "" + dataset_model["additional_html"] = "" + if len(ee_assets) > 0: + dataset_model["additional_html"] = ee_data_html(ee_assets[0]) + else: + dataset_model["additional_html"] = "No results found." + self.dataset_model = json.dumps(dataset_model) + self.host_map.default_style = {"cursor": "default"} + + def _select_dataset(self, dataset_title): + dataset_model = json.loads(self.dataset_model) + dataset_model["additional_html"] = "Loading ..." + datasets = self.host_map.search_datasets + dataset = None + for d in datasets: + if d["title"] == dataset_title: + dataset = d + if not dataset: + return + dataset_html = ee_data_html(dataset) + dataset_model["additional_html"] = dataset_html + self.dataset_model = json.dumps(dataset_model) - search_type.observe(search_type_changed, names="value") - - def search_box_callback(text): - if text.value != "": - if search_type.value == "name/address": - g = geocode(text.value) - elif search_type.value == "lat-lon": - g = geocode(text.value, reverse=True) - if g is None and latlon_from_text(text.value): - search_output.clear_output() - latlon = latlon_from_text(text.value) - m.search_loc_geom = ee.Geometry.Point(latlon[1], latlon[0]) - if m.search_loc_marker is None: - marker = ipyleaflet.Marker( - location=latlon, - draggable=False, - name="Search location", - ) - m.search_loc_marker = marker - m.add(marker) - m.center = latlon - else: - marker = m.search_loc_marker - marker.location = latlon - m.center = latlon - with search_output: - print(f"No address found for {latlon}") - return - elif search_type.value == "data": - search_output.clear_output() - with search_output: - print("Searching ...") - m.default_style = {"cursor": "wait"} - ee_assets = search_ee_data(text.value, source="all") - m.search_datasets = ee_assets - asset_titles = [x["title"] for x in ee_assets] - assets_dropdown.options = asset_titles - search_output.clear_output() - if len(ee_assets) > 0: - assets_dropdown.index = 0 - html_widget.value = ee_data_html(ee_assets[0]) - else: - html_widget.value = "No results found." - with search_output: - display(html_widget) - m.default_style = {"cursor": "default"} + def get_ee_example(self, asset_id): + try: + import pkg_resources - return + pkg_dir = os.path.dirname( + pkg_resources.resource_filename("geemap", "geemap.py") + ) + with open(os.path.join(pkg_dir, "data/gee_f.json"), encoding="utf-8") as f: + functions = json.load(f) + details = [ + dataset["code"] + for x in functions["examples"] + for dataset in x["contents"] + if x["name"] == "Datasets" + if dataset["name"] == asset_id.replace("/", "_") + ] - m.search_locations = g - if g is not None and len(g) > 0: - top_loc = g[0] - latlon = (top_loc.lat, top_loc.lng) - m.search_loc_geom = ee.Geometry.Point(top_loc.lng, top_loc.lat) - if m.search_loc_marker is None: - marker = ipyleaflet.Marker( - location=latlon, - draggable=False, - name="Search location", - ) - m.search_loc_marker = marker - m.add(marker) - m.center = latlon - else: - marker = m.search_loc_marker - marker.location = latlon - m.center = latlon - search_results.options = [x.address for x in g] - search_result_widget.children = [ - search_type, - search_box, - search_output, - ] - with search_output: - search_output.clear_output() - display(search_results) - else: - with search_output: - search_output.clear_output() - print("No results could be found.") + return js_snippet_to_py( + details[0], + add_new_cell=False, + import_ee=False, + import_geemap=False, + show_map=False, + Map=self.host_map._var_name, + ) - search_box.on_submit(search_box_callback) + except Exception as e: + pass + return - search_result_widget = widgets.VBox([search_type, search_box]) + def import_button_clicked(self): + dataset_model = json.loads(self.dataset_model) + print(dataset_model) + if dataset_model["selected"]: + datasets = self.host_map.search_datasets + dataset = None + for d in datasets: + if d["title"] == dataset_model["selected"]: + dataset = d + if not dataset: + return + id_ = dataset["id"] + code = self.get_ee_example(id_) + + if not code: + dataset_uid = "dataset_" + random_string(string_length=3) + translate = { + "image_collection": "ImageCollection", + "image": "Image", + "table": "FeatureCollection", + "table_collection": "FeatureCollection", + } + datatype = translate[dataset["type"]] + id_ = dataset["id"] + line1 = "{} = ee.{}('{}')".format(dataset_uid, datatype, id_) + action = { + "image_collection": f"\n{self.host_map._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", + "image": f"\n{self.host_map._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", + "table": f"\n{self.host_map._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", + "table_collection": f"\n{self.host_map._var_name}.addLayer({dataset_uid}, {{}}, '{id_}')", + } + line2 = action[dataset["type"]] + code = [line1, line2] + + contents = "".join(code).strip() + # create_code_cell(contents) - super().__init__(children=[search_button], **kwargs) + try: + import pyperclip - search_event = ipyevents.Event( - source=self, watched_events=["mouseenter", "mouseleave"] - ) + pyperclip.copy(str(contents)) + except Exception as e: + pass + dataset_model["additional_html"] = ( + "
"
+                "# The code has been copied to the clipboard.\n"
+                "# Press Ctrl+V in a new cell to paste it.\n"
+                f"{contents}"
+                " {
+    static get componentName() {
+        return `search-bar`;
+    }
+
+    static styles = [
+        legacyStyles,
+        materialStyles,
+        css`
+            .row {
+                display: flex;
+            }
+
+            .expand-button {
+                border-radius: 5px;
+                font-size: 16px;
+                height: 28px;
+                margin: 2px;
+                user-select: none;
+                width: 28px;
+            }
+
+            .hide {
+                display: none;
+            }
+
+            .expanded {
+                display: block; !important
+            }
+
+            input.search {
+                margin: 2px;
+                width: calc(100% - 4px);
+            }
+
+            ul.results {
+                list-style-type: none;
+                margin: 0;
+                margin-bottom: 4px;
+                max-width: 340px;
+                padding: 0;
+            }
+
+            label.result {
+                align-items: center;
+                display: flex;
+            }
+
+            .import-button, .reset-button {
+                margin: 0 2px 2px 2px;
+                padding: 0 8px;
+            }
+
+            .dataset-select {
+                margin-bottom: 2px;
+                margin-right: 2px;
+                max-width: 285px;
+            }
+
+            .additional-html-container {
+                max-height: 300px;
+                max-width: 340px;
+                overflow: auto;
+            }
+
+            .additional-html-container pre {
+                white-space: break-spaces;
+            }
+        `,
+    ];
+
+    modelNameToViewName(): Map {
+        return new Map([
+            ["expanded", "expanded"],
+            ["tab_index", "tab_index"],
+            ["name_address_model", "nameAddressModel"],
+            ["lat_lon_model", "latLonModel"],
+            ["dataset_model", "datasetModel"],
+        ]);
+    }
+
+    @property()
+    expanded: boolean = false;
+
+    @property()
+    tab_index: number = 0;
+
+    @property()
+    nameAddressModel: string = JSON.stringify({
+        search: "",
+        results: [],
+        selected: "",
+        additional_html: "",
+    });
+
+    @property()
+    latLonModel: string = JSON.stringify({
+        search: "",
+        results: [],
+        selected: "",
+        additional_html: "",
+    });
+
+    @property()
+    datasetModel: string = JSON.stringify({
+        search: "",
+        results: [],
+        selected: "",
+        additional_html: "",
+    });
+
+    @query(".name-address-search")
+    nameAddressSearch!: HTMLInputElement;
+
+    @queryAll(".name-address-results input")
+    nameAddressResults!: HTMLInputElement[];
+
+    @query(".lat-lon-search")
+    latLonSearch!: HTMLInputElement;
+
+    @queryAll(".lat-lon-results input")
+    latLonResults!: HTMLInputElement[];
+
+    @query(".dataset-search")
+    datasetSearch!: HTMLInputElement;
+
+    render() {
+        return html`
+            
+ + ) => { + this.tab_index = e.detail; + }} + .mode="${TabMode.ALWAYS_SHOW}" + class="${classMap({ + hide: !this.expanded, + expanded: this.expanded, + })}"> +
+ ${this.renderNameAddressSearch()} +
+
+ ${this.renderLatLonSearch()} +
+
+ ${this.renderDatasetSearch()} +
+
+
+ `; + } + + private onExpandClick(_: Event) { + this.expanded = !this.expanded; + } + + private renderNameAddressSearch() { + const nameAddressModel = JSON.parse(this.nameAddressModel) as SearchTab; + const searchInput = html``; + const renderedInputs = [searchInput]; + if (nameAddressModel.results.length) { + const results = html` + ${nameAddressModel.results.map((result) => html` +
  • + +
  • `)} + `; + renderedInputs.push(html`
      + ${results} +
    `); + } + renderedInputs.push(html`
    + ${unsafeHTML(nameAddressModel.additional_html)} +
    `); + if (nameAddressModel.search || + nameAddressModel.results.length || + nameAddressModel.selected) { + renderedInputs.push(html``) + } + return renderedInputs; + } + + private renderLatLonSearch() { + const latLonModel = JSON.parse(this.latLonModel) as SearchTab; + const searchInput = html``; + const renderedInputs = [searchInput]; + if (latLonModel.results.length) { + const results = html` + ${latLonModel.results.map((result, i) => html` +
  • + +
  • + `)} + `; + renderedInputs.push(html`
      + ${results} +
    `); + } + renderedInputs.push(html`
    + ${unsafeHTML(latLonModel.additional_html)} +
    `); + if (latLonModel.search || + latLonModel.results.length || + latLonModel.selected) { + renderedInputs.push(html``) + } + return renderedInputs; + } + + private renderDatasetSearch() { + const datasetModel = JSON.parse(this.datasetModel) as SearchTab; + const searchInput = html``; + const renderedInputs = [searchInput]; + const importButton = html``; + const results = html` + + `; + renderedInputs.push( + html`
    + ${importButton} + ${results} +
    `, + html`
    + ${unsafeHTML(datasetModel.additional_html)} +
    `); + return renderedInputs; + } +} + +// Without this check, there's a component registry issue when developing locally. +if (!customElements.get(SearchBar.componentName)) { + customElements.define(SearchBar.componentName, SearchBar); +} + +async function render({ model, el }: RenderProps) { + loadFonts(); + const row = ( + document.createElement(SearchBar.componentName) + ); + row.model = model; + el.appendChild(row); +} + +export default { render }; diff --git a/js/tab_panel.ts b/js/tab_panel.ts index cfddc70d0b..b24b889fb4 100644 --- a/js/tab_panel.ts +++ b/js/tab_panel.ts @@ -1,10 +1,11 @@ import { html, css, nothing, LitElement, PropertyValues } from "lit"; import { property, queryAll, queryAssignedElements } from "lit/decorators.js"; -import { legacyStyles } from "./ipywidgets_styles"; import { classMap } from "lit/directives/class-map.js"; -import { materialStyles } from "./styles"; import { styleMap } from "lit/directives/style-map.js"; +import { legacyStyles } from "./ipywidgets_styles"; +import { materialStyles } from "./styles"; + function convertToId(name: string | undefined): string { return (name || "").trim().replace(" ", "-").toLowerCase(); } diff --git a/tests/search_bar.spec.ts b/tests/search_bar.spec.ts new file mode 100644 index 0000000000..de362b08d6 --- /dev/null +++ b/tests/search_bar.spec.ts @@ -0,0 +1,128 @@ +import { AnyModel } from "@anywidget/types"; +import { default as searchBarRender, SearchBar, SearchBarModel } from "../js/search_bar"; +import { FakeAnyModel } from "./fake_anywidget"; + +import "../js/search_bar"; + +describe("", () => { + let searchBar: SearchBar; + + async function makesearchBar(model: AnyModel) { + const container = document.createElement("div"); + searchBarRender.render({ + model, el: container, experimental: { + invoke: () => new Promise(() => [model, []]), + } + }); + const element = container.firstElementChild as SearchBar; + document.body.appendChild(element); + await element.updateComplete; + return element; + } + + beforeEach(async () => { + searchBar = await makesearchBar(new FakeAnyModel({ + expanded: false, + tab_index: 0, + name_address_model: JSON.stringify({ + search: "", + results: [], + selected: "", + additional_html: "", + }), + lat_lon_model: JSON.stringify({ + search: "", + results: [], + selected: "", + additional_html: "", + }), + dataset_model: JSON.stringify({ + search: "", + results: [], + selected: "", + additional_html: "", + }), + })); + }); + + afterEach(() => { + Array.from(document.querySelectorAll("search-bar")).forEach((el) => { + el.remove(); + }) + }); + + it("can be instantiated.", () => { + expect(searchBar.shadowRoot?.querySelector("tab-panel")).toBeDefined(); + }); + + it("renders and updates the name address search", async () => { + searchBar.name_address_model = JSON.stringify({ + search: "my city", + results: ["my city 1", "my city 2"], + selected: "my city 1", + additional_html: `

    An extra message

    `, + }) + await searchBar.updateComplete; + expect(searchBar.nameAddressSearch).toBeDefined(); + expect(searchBar.nameAddressResults).toBeDefined(); + expect(searchBar.nameAddressResults[0].checked).toBeTrue(); + const results = Array.from(searchBar.nameAddressResults).map((el) => el.value); + expect(results).toEqual(["my city 1", "my city 2"]); + expect(searchBar.shadowRoot!.querySelector(".name-address-extra")).toBeDefined(); + expect(searchBar.shadowRoot!.querySelector(".name-address-container .reset-button")).toBeDefined(); + + jasmine.clock().install(); + searchBar.nameAddressSearch.value = "my new search"; + searchBar.nameAddressSearch.dispatchEvent(new Event("input")); + jasmine.clock().tick(500); + expect(JSON.parse(searchBar.name_address_model).search).toBe("my new search"); + jasmine.clock().uninstall(); + + searchBar.nameAddressResults[1].checked = true; + searchBar.nameAddressResults[1].dispatchEvent(new Event("input")); + expect(JSON.parse(searchBar.name_address_model).selected).toBe("my city 2"); + }); + + it("renders and updates the lat-lon search", async () => { + searchBar.lat_lon_model = JSON.stringify({ + search: "40, -100", + results: ["my cool city"], + selected: "my cool city", + additional_html: `

    An extra message

    `, + }) + await searchBar.updateComplete; + expect(searchBar.latLonSearch).toBeDefined(); + expect(searchBar.latLonResults).toBeDefined(); + expect(searchBar.latLonResults[0].checked).toBeTrue(); + const results = Array.from(searchBar.latLonResults).map((el) => el.value); + expect(results).toEqual(["my cool city"]); + expect(searchBar.shadowRoot!.querySelector(".lat-lon-extra")).toBeDefined(); + expect(searchBar.shadowRoot!.querySelector(".lat-lon-container .reset-button")).toBeDefined(); + + jasmine.clock().install(); + searchBar.latLonSearch.value = "my new search"; + searchBar.latLonSearch.dispatchEvent(new Event("input")); + jasmine.clock().tick(500); + expect(JSON.parse(searchBar.lat_lon_model).search).toBe("my new search"); + jasmine.clock().uninstall(); + }); + + it("renders and updates the dataset search", async () => { + searchBar.dataset_model = JSON.stringify({ + search: "elevation", + results: ["dataset 1", "dataset 2"], + selected: "dataset 1", + additional_html: `

    A cool dataset

    `, + }) + await searchBar.updateComplete; + expect(searchBar.datasetSearch).toBeDefined(); + expect(searchBar.shadowRoot!.querySelector(".dataset-extra")).toBeDefined(); + + jasmine.clock().install(); + searchBar.datasetSearch.value = "my new search"; + searchBar.datasetSearch.dispatchEvent(new Event("input")); + jasmine.clock().tick(500); + expect(JSON.parse(searchBar.dataset_model).search).toBe("my new search"); + jasmine.clock().uninstall(); + }); +}); \ No newline at end of file