diff --git a/docs/main.py b/docs/main.py index 20dfd7014..7a680b39c 100644 --- a/docs/main.py +++ b/docs/main.py @@ -46,18 +46,16 @@ def make_component(): # Modify the run function so when we exec the file # instead of running a server we mount the view. idom.run = partial(mount.add, file.stem) - - with file.open() as f: - try: - exec( - f.read(), - { - "__file__": str(file), - "__name__": f"__main__.examples.{file.stem}", - }, - ) - except Exception as error: - raise RuntimeError(f"Failed to execute {file}") from error + try: + exec( + file.read_text(), + { + "__file__": str(file.absolute()), + "__name__": f"__main__.examples.{file.stem}", + }, + ) + except Exception as error: + raise RuntimeError(f"Failed to execute {file}") from error finally: idom.run = original_run diff --git a/docs/source/_static/custom.js b/docs/source/_static/custom.js index 731d93d5e..b2206d073 100644 --- a/docs/source/_static/custom.js +++ b/docs/source/_static/custom.js @@ -1398,7 +1398,9 @@ function useJsonPatchCallback(initial) { // We CANNOT mutate the part of the document because React checks some // attributes of the model (e.g. model.attributes.style is checked for // identity). - doc.current = applyNonMutativePatch(doc.current, patch); + doc.current = applyNonMutativePatch( + doc.current, + patch); } else { // We CAN mutate the document here though because we know that nothing above // The patch `path` is changing. Thus, maintaining the identity for that section @@ -1965,11 +1967,6 @@ function _nextReconnectTimeout(maxReconnectTimeout, mountState) { Math.floor(Math.random() * mountState.reconnectTimeoutRange) || 1; mountState.reconnectTimeoutRange = (mountState.reconnectTimeoutRange + 5) % maxReconnectTimeout; - if (mountState.reconnectAttempts === 4) { - window.alert( - "Server connection was lost. Attempts to reconnect are being made in the background." - ); - } return timeout; } diff --git a/docs/source/examples.rst b/docs/source/examples.rst index 216902bcc..5e0b5cebf 100644 --- a/docs/source/examples.rst +++ b/docs/source/examples.rst @@ -62,15 +62,15 @@ you're experimenting: Define Javascript Modules ------------------------- -Click the bars to trigger an event πŸ‘‡ +Shows a very simple chart implemented in vanilla Javascript: .. example:: super_simple_chart -Material UI Slider +Material UI Button ------------------ -Move the slider and see the event information update πŸ‘‡ +Click the button to change the indicator πŸ‘‡ .. example:: material_ui_switch @@ -83,6 +83,14 @@ Click the map to create pinned location πŸ“: .. example:: pigeon_maps +Cytoscape Notework Graph +------------------------ + +You can move the nodes in the graph πŸ•ΈοΈ: + +.. example:: network_graph + + .. Links .. ===== diff --git a/docs/source/examples/network_graph.py b/docs/source/examples/network_graph.py new file mode 100644 index 000000000..3dfb9ae87 --- /dev/null +++ b/docs/source/examples/network_graph.py @@ -0,0 +1,43 @@ +import random + +import idom + + +react_cytoscapejs = idom.web.module_from_template( + # we need to use this template because react-cytoscapejs uses a default export + "react", + "react-cytoscapejs", + exports_default=True, + fallback="βŒ›", +) +Cytoscape = idom.web.export(react_cytoscapejs, "default") + + +@idom.component +def RandomNetworkGraph(): + return Cytoscape( + { + "style": {"width": "100%", "height": "200px"}, + "elements": random_network(20), + "layout": {"name": "cose"}, + } + ) + + +def random_network(number_of_nodes): + conns = [] + nodes = [{"data": {"id": 0, "label": 0}}] + + for src_node_id in range(1, number_of_nodes + 1): + tgt_node = random.choice(nodes) + src_node = {"data": {"id": src_node_id, "label": src_node_id}} + + new_conn = {"data": {"source": src_node_id, "target": tgt_node["data"]["id"]}} + + nodes.append(src_node) + conns.append(new_conn) + + return nodes + conns + + +idom.run(RandomNetworkGraph) diff --git a/docs/source/index.rst b/docs/source/index.rst index f095d4d14..a83bd26a2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -70,22 +70,22 @@ A package for building responsive user interfaces in pure Python. .. grid-item-card:: - .. interactive-widget:: snake_game + .. interactive-widget:: network_graph :no-activate-button: .. grid-item-card:: - .. interactive-widget:: slideshow + .. interactive-widget:: snake_game :no-activate-button: .. grid-item-card:: - .. interactive-widget:: audio_player + .. interactive-widget:: slideshow :no-activate-button: .. grid-item-card:: - .. interactive-widget:: todo + .. interactive-widget:: audio_player :no-activate-button: .. grid-item:: @@ -98,8 +98,6 @@ A package for building responsive user interfaces in pure Python. .. interactive-widget:: simple_dashboard :no-activate-button: - - .. grid-item-card:: .. interactive-widget:: matplotlib_plot @@ -109,3 +107,8 @@ A package for building responsive user interfaces in pure Python. .. interactive-widget:: material_ui_button_on_click :no-activate-button: + + .. grid-item-card:: + + .. interactive-widget:: todo + :no-activate-button: diff --git a/src/client/packages/idom-client-react/src/json-patch.js b/src/client/packages/idom-client-react/src/json-patch.js index d79a80caa..5323f11a9 100644 --- a/src/client/packages/idom-client-react/src/json-patch.js +++ b/src/client/packages/idom-client-react/src/json-patch.js @@ -11,7 +11,13 @@ export function useJsonPatchCallback(initial) { // We CANNOT mutate the part of the document because React checks some // attributes of the model (e.g. model.attributes.style is checked for // identity). - doc.current = applyNonMutativePatch(doc.current, patch, false, false, true); + doc.current = applyNonMutativePatch( + doc.current, + patch, + false, + false, + true + ); } else { // We CAN mutate the document here though because we know that nothing above // The patch `path` is changing. Thus, maintaining the identity for that section diff --git a/src/idom/web/module.py b/src/idom/web/module.py index 1d4f6b857..32e5dcc9d 100644 --- a/src/idom/web/module.py +++ b/src/idom/web/module.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from functools import partial from pathlib import Path +from string import Template from typing import Any, List, NewType, Optional, Set, Tuple, Union, overload from urllib.parse import urlparse @@ -83,6 +84,7 @@ def module_from_template( package: str, cdn: str = "https://esm.sh", fallback: Optional[Any] = None, + exports_default: bool = False, resolve_exports: bool = IDOM_DEBUG_MODE.current, resolve_exports_depth: int = 5, unmount_before_update: bool = False, @@ -99,10 +101,13 @@ def module_from_template( warning.cIt's best to author a module adhering to the :ref:`Custom Javascript Component` interface instead. + **Templates** + + - ``react``: for modules exporting React components + Parameters: template: - The name of the framework template to use with the given ``package`` - (only ``react`` is supported at the moment). + The name of the framework template to use with the given ``package``. package: The name of a package to load. May include a file extension (defaults to ``.js`` if not given) @@ -110,6 +115,8 @@ def module_from_template( Where the package should be loaded from. The CDN must distribute ESM modules fallback: What to temporarilly display while the module is being loaded. + exports_default: + Whether the module has a default export. resolve_imports: Whether to try and find all the named exports of this module. resolve_exports_depth: @@ -128,7 +135,12 @@ def module_from_template( # downstream code assumes no trailing slash cdn = cdn.rstrip("/") - template_file_name = f"{template}{module_name_suffix(package_name)}" + template_file_name = ( + template + + (".default" if exports_default else "") + + module_name_suffix(package_name) + ) + template_file = Path(__file__).parent / "templates" / template_file_name if not template_file.exists(): raise ValueError(f"No template for {template_file_name!r} exists") @@ -137,7 +149,9 @@ def module_from_template( if not target_file.exists(): target_file.parent.mkdir(parents=True, exist_ok=True) target_file.write_text( - template_file.read_text().replace("$PACKAGE", package).replace("$CDN", cdn) + Template(template_file.read_text()).substitute( + {"PACKAGE": package, "CDN": cdn} + ) ) return WebModule( @@ -146,7 +160,7 @@ def module_from_template( default_fallback=fallback, file=target_file, export_names=( - resolve_module_exports_from_url(f"{cdn}/{package}", resolve_exports_depth) + resolve_module_exports_from_file(target_file, resolve_exports_depth) if resolve_exports else None ), diff --git a/src/idom/web/templates/react.default.js b/src/idom/web/templates/react.default.js new file mode 100644 index 000000000..bc450a4f6 --- /dev/null +++ b/src/idom/web/templates/react.default.js @@ -0,0 +1,48 @@ +export default from "$CDN/$PACKAGE"; +export * from "$CDN/$PACKAGE"; + +import * as React from "$CDN/react"; +import * as ReactDOM from "$CDN/react-dom"; + +export function bind(node, config) { + return { + create: (component, props, children) => + React.createElement(component, wrapEventHandlers(props), ...children), + render: (element) => ReactDOM.render(element, node), + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; +} + +function wrapEventHandlers(props) { + const newProps = Object.assign({}, props); + for (const [key, value] of Object.entries(props)) { + if (typeof value === "function") { + newProps[key] = makeJsonSafeEventHandler(value); + } + } + return newProps; +} + +function makeJsonSafeEventHandler(oldHandler) { + // Since we can't really know what the event handlers get passed we have to check if + // they are JSON serializable or not. We can allow normal synthetic events to pass + // through since the original handler already knows how to serialize those for us. + return function safeEventHandler() { + oldHandler( + ...Array.from(arguments).filter((value) => { + if (typeof value === "object" && value.nativeEvent) { + // this is probably a standard React synthetic event + return true; + } else { + try { + JSON.stringify(value); + } catch (err) { + console.error("Failed to serialize some event data"); + return false; + } + return true; + } + }) + ); + }; +} diff --git a/src/idom/web/utils.py b/src/idom/web/utils.py index 5c8ae6718..2916cbb98 100644 --- a/src/idom/web/utils.py +++ b/src/idom/web/utils.py @@ -18,7 +18,11 @@ def module_name_suffix(name: str) -> str: return PurePosixPath(tail or head).suffix or ".js" -def resolve_module_exports_from_file(file: Path, max_depth: int) -> Set[str]: +def resolve_module_exports_from_file( + file: Path, + max_depth: int, + is_re_export: bool = False, +) -> Set[str]: if max_depth == 0: logger.warning(f"Did not resolve all exports for {file} - max depth reached") return set() @@ -26,19 +30,29 @@ def resolve_module_exports_from_file(file: Path, max_depth: int) -> Set[str]: logger.warning(f"Did not resolve exports for unknown file {file}") return set() - export_names, references = resolve_module_exports_from_source(file.read_text()) + export_names, references = resolve_module_exports_from_source( + file.read_text(), exclude_default=is_re_export + ) for ref in references: if urlparse(ref).scheme: # is an absolute URL - export_names.update(resolve_module_exports_from_url(ref, max_depth - 1)) + export_names.update( + resolve_module_exports_from_url(ref, max_depth - 1, is_re_export=True) + ) else: path = file.parent.joinpath(*ref.split("/")) - export_names.update(resolve_module_exports_from_file(path, max_depth - 1)) + export_names.update( + resolve_module_exports_from_file(path, max_depth - 1, is_re_export=True) + ) return export_names -def resolve_module_exports_from_url(url: str, max_depth: int) -> Set[str]: +def resolve_module_exports_from_url( + url: str, + max_depth: int, + is_re_export: bool = False, +) -> Set[str]: if max_depth == 0: logger.warning(f"Did not resolve all exports for {url} - max depth reached") return set() @@ -50,16 +64,22 @@ def resolve_module_exports_from_url(url: str, max_depth: int) -> Set[str]: logger.warning("Did not resolve exports for url " + url + reason) return set() - export_names, references = resolve_module_exports_from_source(text) + export_names, references = resolve_module_exports_from_source( + text, exclude_default=is_re_export + ) for ref in references: url = _resolve_relative_url(url, ref) - export_names.update(resolve_module_exports_from_url(url, max_depth - 1)) + export_names.update( + resolve_module_exports_from_url(url, max_depth - 1, is_re_export=True) + ) return export_names -def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str]]: +def resolve_module_exports_from_source( + content: str, exclude_default: bool +) -> Tuple[Set[str], Set[str]]: names: Set[str] = set() references: Set[str] = set() @@ -69,7 +89,9 @@ def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str] # Exporting functions and classes names.update(_JS_FUNC_OR_CLS_EXPORT_PATTERN.findall(content)) + print(content) for export in _JS_GENERAL_EXPORT_PATTERN.findall(content): + print(export) export = export.rstrip(";").strip() # Exporting individual features if export.startswith("let "): @@ -100,7 +122,14 @@ def resolve_module_exports_from_source(content: str) -> Tuple[Set[str], Set[str] ) elif not (export.startswith("function ") or export.startswith("class ")): logger.warning(f"Unknown export type {export!r}") - return {n.strip() for n in names}, {r.strip() for r in references} + + names = {n.strip() for n in names} + references = {r.strip() for r in references} + + if exclude_default and "default" in names: + names.remove("default") + + return names, references def _resolve_relative_url(base_url: str, rel_url: str) -> str: @@ -126,5 +155,5 @@ def _resolve_relative_url(base_url: str, rel_url: str) -> str: r";?\s*export\s+(?:function|class)\s+([a-zA-Z_$][0-9a-zA-Z_$]*)" ) _JS_GENERAL_EXPORT_PATTERN = re.compile( - r";?\s*export(?=\s+|{)(.*?)(?:;|$)", re.MULTILINE + r"(?:^|;)\s*export(?=\s+|{)(.*?)(?=;|$)", re.MULTILINE ) diff --git a/tests/test_web/js_fixtures/export-resolution/one.js b/tests/test_web/js_fixtures/export-resolution/one.js index a0355241f..1d404b1cc 100644 --- a/tests/test_web/js_fixtures/export-resolution/one.js +++ b/tests/test_web/js_fixtures/export-resolution/one.js @@ -1,3 +1,5 @@ export {one as One}; // use ../ just to check that it works export * from "../export-resolution/two.js"; +// this default should not be exported by the * re-export in index.js +export default 0; diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index 3ed31b39e..149c6eb8f 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -127,13 +127,15 @@ def test_resolve_module_exports_from_url_log_on_bad_response(caplog): ], ) def test_resolve_module_default_exports_from_source(text): - names, references = resolve_module_exports_from_source(text) + names, references = resolve_module_exports_from_source(text, exclude_default=False) assert names == {"default"} and not references def test_resolve_module_exports_from_source(): fixture_file = JS_FIXTURES_DIR / "exports-syntax.js" - names, references = resolve_module_exports_from_source(fixture_file.read_text()) + names, references = resolve_module_exports_from_source( + fixture_file.read_text(), exclude_default=False + ) assert ( names == ( @@ -145,3 +147,11 @@ def test_resolve_module_exports_from_source(): ) and references == {"https://source1.com", "https://source2.com"} ) + + +def test_log_on_unknown_export_type(caplog): + assert resolve_module_exports_from_source( + "export something unknown;", exclude_default=False + ) == (set(), set()) + assert len(caplog.records) == 1 + assert caplog.records[0].message.startswith("Unknown export type ")