holoviz / panel

Panel: The powerful data exploration & web app framework for Python
https://panel.holoviz.org
BSD 3-Clause "New" or "Revised" License
4.52k stars 492 forks source link

Make it possible to use React Components in _template of ReactiveHTML #2836

Open MarcSkovMadsen opened 2 years ago

MarcSkovMadsen commented 2 years ago

Feature

I would like to be able to wrap React components in ReactiveHTML easily. This includes MaterialUI, ReactBootstrap, Ant and many more. I would like to be able to do something as simple as

import param
from panel.reactive import ReactiveHTML

class MaterialButton(ReactiveHTML):
    class_name = param.String("pnc-material-button")
    disabled=param.Boolean()

    value = param.Event(precedence=-1)
    clicks = param.Integer(default=0)
    button_type = param.String("default")

    variant = param.String("outlined")
    color = param.String("primary")
    disable_elevation = param.Boolean()
    disable_focusRipple = param.Boolean()
    disable_ripple = param.Boolean()

    tooltip = param.String("Click Me!")
    tooltip_placement = param.String(default="bottom")

    width = param.Integer(default=300, bounds=(0, None))
    height = param.Integer(default=36, bounds=(0, None))

    _template = """
<MaterialUI.Tooltip title=${tooltip} placement=${tooltip_placement}>
    <MaterialUI.Button
    className=${class_name}
    disabled=${disabled}
    variant=${variant}
    color=${color}
    disableElevation=${disable_elevation}
    disableFocusRipple=${disable_focus_ripple}
    disableRipple=${disable_ripple}
    onClick="${scripts('click')}"
    >${name}<//>
<//>
"""

    _scripts = {
        "click": "data.clicks += 1"
    }

    __javascript__ = [
        # "https://unpkg.com/react@17.0.2/umd/react.production.min.js",
        # "https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js",
        "https://unpkg.com/@material-ui/core@4.12.3/umd/material-ui.development.js",
    ]

    __css__ = [
        "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap",
        "https://fonts.googleapis.com/icon?family=Material+Icons",
    ]

The special requirement here is to be able to use the react components as tags inside my _template. I believe it should be possible via preact, htm and maybe preact webcomponents on top of which ReactiveHTML is built. I've tried to so far have not been successful to create something.

Context

I'm trying to wrap React Component into Panel components here https://github.com/marcskovmadsen/panel-components. After having worked through different example of Ant, MaterialUi, ReactBootstrap components I feel ReactiveHTML does not make it easy to wrap React components.

With inspiration from @xavArtley here https://discourse.holoviz.org/t/material-ui-framework-with-reactivehtml/2723/5?u=marc the concept for wrapping a MaterialUI Button looks like

class MaterialButton(ReactiveHTML):
    _template = """<div id="component" class="pnc-container"></div>"""

    _scripts = {
        "render": "state.component=component;self.updateElement()",
        "_css_names": "self.updateElement()",
        "disabled": "self.updateElement()",
        "variant": "self.updateElement()",
        "color": "self.updateElement()",
        "disable_elevation": "self.updateElement()",
        "disable_focus_ripple": "self.updateElement()",
        "disable_ripple": "self.updateElement()",
        "tooltip": "self.updateElement()",
        "tooltip_placement": "self.updateElement()",
        "tooltip_configuration": "self.updateElement()",
        "updateElement": (
            "config={className:data._css_names,disabled:data.disabled,variant:data.variant,color:data.color,disableElevation:data.disable_elevation,disableFocusRipple:data.disable_focus_ripple,disableRipple:data.disable_ripple,onClick:()=>{data.clicks=data.clicks+1},...data.configuration};"
            "element=React.createElement(MaterialUI.Button,config,data.name);"
            "element=React.createElement(MaterialUI.Tooltip,{title:data.tooltip,placement:data.tooltip_placement,...data.tooltip_configuration},element)"
            ";ReactDOM.unmountComponentAtNode(state.component);"
            "ReactDOM.render(element,state.component)",
    )
    ...

The problem with this is 1) why do I need so many self.updateElement scripts 2) The updateElement quickly becomes bloated and unreadable if I need to nest a few react components like the MaterialUI Button and Tooltip in this example.

I've created some help functionality to make this more general

GENERATOR = MaterialGenerator(
    element="MaterialUI.Button",
    properties={
        "variant": "variant",
        "disabled": "disabled",
        "className": "_css_names",
        "color": "color",
        "disableElevation": "disable_elevation",
        "disableFocusRipple": "disable_focus_ripple",
        "disableRipple": "disable_ripple",
    },
    events={"click": "data.clicks = data.clicks + 1"},
    children="name",
)
class MaterialButton(ReactiveHTML):
   _template = GENERATOR.create_template()
   _template = GENERATOR.create_scripts()

   ...

Its sort of simple and nice. The problem is still that a method in MaterialGenerator still needs to generate the nesting of the MaterialUI Button and Tooltip. It quickly becomes un-readable and un-writable.

I see two solutions. One is to make a python wrapper around React.createElement the other is to make the _template of ReactiveHTML more powerful. The latter is this Feature Request.

Ideas

Maybe some kind of on_creation life cycle hook is need that can be used to register React components as Web Components before the first render of the template. then the React components could be used as web components in the template.

Even better would just be some way to refer to MaterialUI.Button etc inside the _template.

philippjfr commented 2 years ago

Using webcomponents this should already work but for general react support I really need some code that dynamically imports React modules, which is not straightforward. I don't think there's any way to wrap arbitrary React components in a webcomponent in a general way.

MarcSkovMadsen commented 2 years ago

I ended up creating a python ReactGenerator functionality.

class MaterialWidget(pn.reactive.ReactiveHTML):

    @staticmethod
    def _scripts(element, properties, events, children):
        widget = ReactGenerator(
            element=element,
            properties=properties,
            events=events,
            children=children,
        )
        widget_with_tooltip = ReactGenerator(
            element="MaterialUI.Tooltip",
            properties={"title": "tooltip", "placement": "tooltip_placement"},
            children=[widget],
        )
        return widget_with_tooltip.scripts

I expect to be able to generate the _scripts of any materialui widget something like

_scripts=MaterialWidget._scripts(
        element="MaterialUI.Button",
        properties={
            # Widget
            "autofocus": "autofocus",
            "className": "_css_names",
            "disabled": "disabled",
            # MaterialButton
            "color": "color",
            "disableElevation": "disable_elevation",
            "disableFocusRipple": "disable_focus_ripple",
            "disableRipple": "disable_ripple",
            "fullWidth": "full_width",
            "size": "size",
            "variant": "variant",
        },
        events={
            "onClick": "data.clicks += 1"
        },
        children=["data.name"]
    )

Similarly for reactbootstrap, ant etc.

Full Code

from typing import Dict

import param
import pytest
import panel as pn
from panel.widgets.button import BUTTON_TYPES
from collections import namedtuple

class ReactGenerator(param.Parameterized):
    element = param.String()
    id = param.String("component")
    properties = param.Dict({})
    events = param.Dict({})
    children = param.List()

    _events: Dict[str, str] = {}
    _properties: Dict[str, str] = {}

    @property
    def template(self) -> str:
        """Returns the `_template` of the component"""
        return f"""<div id="{self.id}" class="pnc-component"></div>"""

    def _create_element(self, child):
        if isinstance(child, ReactGenerator):
            return child.create_element
        elif isinstance(child, str):
            return child
        else:
            raise NotImplementedError()

    @property
    def all_properties(self):
        return {**self._properties, **self.properties}

    @property
    def all_events(self):
        return {**self._events, **self.events}

    @property
    def configuration(self):
        properties = {k: "data." + v for k, v in self.all_properties.items()}
        events = {k: f"()=>{{{v}}}" for k, v in self.all_events.items()}

        return {**properties, **events}

    @property
    def create_element(self):
        configuration = self.configuration
        configuration_str = f"{configuration}".replace("'", "")
        children = [self._create_element(child) for child in self.children]
        if len(children) == 1:
            children_str = children[0]
        else:
            children_str = f"{children}".replace("'", "")
        return f"""React.createElement({self.element},{configuration_str},{children_str})"""

    @property
    def update_element(self):
        return (
            f"""element={self.create_element};"""
            """ReactDOM.unmountComponentAtNode(state.component);"""
            """ReactDOM.render(element,state.component)"""
        )

    @property
    def update_scripts(self):
        if self.all_properties:
            scripts = {v: "self.updateElement()" for v in self.all_properties.values()}
        else:
            scripts = {}
        for child in self.children:
            if isinstance(child, ReactGenerator):
                scripts.update(child.update_scripts)
        return scripts

    @property
    def render_script(self):
        return "state.component=component;self.updateElement()"

    @property
    def scripts(self) -> Dict:
        """Returns the `_scripts` of the component"""
        return {
            "render": self.render_script,
            "updateElement": self.update_element,
            **self.update_scripts,
        }

class WidgetBase(pn.reactive.ReactiveHTML):  # pylint: disable=too-few-public-methods, too-many-ancestors
    """Base Widget Class. You `panel_component` widgets should inherit from this"""

    name = param.String(default="")
    tooltip = param.String()
    tooltip_placement = param.String("default")
    css_names = param.List([])
    disabled = param.Boolean(
        default=False,
        doc="""
       Whether the widget is disabled.""",
    )
    autofocus = param.Boolean(
        default=False,
        doc="""The autofocus attribute. Defaults to `False`""",
    )
    margin = param.Parameter(
        default=(5, 10),
        doc="""
        Allows to create additional space around the component. May
        be specified as a two-tuple of the form (vertical, horizontal)
        or a four-tuple (top, right, bottom, left).""",
    )
    _css_names = param.String("")

    _scripts = {
        "render": (
            "component.disabled=data.disabled;"
            "component.title=data.tooltip;"
            "component.autofocus=data.autofocus;"
            "component.className=data._css_names"
        ),
        "disabled": "component.disabled=data.disabled",
        "tooltip": "component.title=data.tooltip",
        "autofocus": "component.autofocus=data.autofocus",
        "_css_names": "component.className=data._css_names",
    }
    _properties = {
        "disabled": "disabled",
        "title": "tooltip",
        "autofocus": "autofocus",
        "className": "_css_names",
    }
    _events: Dict[str, str] = {}

    def __init__(self, **params):
        super().__init__(**params)

        self.param.watch(self._handle_css_names_changed, "css_names")

        self._handle_css_names_changed()

    def _handle_css_names_changed(self, event=None):  # pylint: disable=unused-argument
        css_names = self._get_css_names()
        self._set_css_names(css_names)

    def _get_css_names(self):
        return list(set(self._css_names_component + self.css_names))

    def _set_css_names(self, css_names):
        with param.edit_constant(self):
            self._css_names = " ".join(css_names)

class ButtonBase(WidgetBase):  # pylint: disable=too-many-ancestors
    """The Buttons in `panel_components` should inherit from this"""

    value = param.Event(precedence=-1)
    clicks = param.Integer(default=0)
    button_type = param.ObjectSelector(default="default", objects=BUTTON_TYPES)
    css_names = param.List([])

    height = param.Integer(default=32, bounds=(0, None))
    width = param.Integer(default=300, bounds=(0, None))

    _css_names = param.String("")

    _scripts = {
        **WidgetBase._scripts,
        "click": "data.clicks += 1",
    }
    _properties = {
        **WidgetBase._properties
    }
    _events = {
        **WidgetBase._events,
        "onClick": "data.clicks += 1",
    }

    _css_names_component = ["pnc-widget"]

    def __init__(self, **params):
        super().__init__(**params)

        self.param.watch(self._handle_button_type_changed, "button_type")
        self.param.watch(self._handle_css_names_changed, "css_names")

        self._handle_button_type_changed()
        self._handle_css_names_changed()

    @param.depends("clicks", watch=True)
    def _trigger_value_event(self):
        self.param.trigger("value")

    def _handle_button_type_changed(self, event=None):
        self._handle_css_names_changed(event=event)

    def _handle_css_names_changed(self, event=None):  # pylint: disable=unused-argument
        css_names = self._get_css_names()
        self._set_css_names(css_names)

    def _get_css_names(self):
        return list(set(self._css_names_component + self.css_names))

    def _set_css_names(self, css_names):
        with param.edit_constant(self):
            self._css_names = " ".join(css_names)

    @classmethod
    def example(cls):
        raise NotImplementedError()

TOOLTIP_PLACEMENTS = [
    "bottom-end",
    "bottom-start",
    "bottom",
    "left-end",
    "left-start",
    "left",
    "right-end",
    "right-start",
    "right",
    "top-end",
    "top-start",
    "top",
]

_Config = namedtuple("_Config", "variant color")
BUTTON_TYPE_MAP = {
    "default": _Config("outlined", "primary"),
    "primary": _Config("contained", "primary"),
    "success": _Config("contained", "success"),
    "warning": _Config("contained", "warning"),
    "danger": _Config("contained", "error"),
    "light": _Config("text", "primary"),
}

SIZE_MAP = {
    "small": 32,  # MaterialUI is 30.75 but we keep this consistent with Panels default
    "medium": 36,
    "large": 42,
}
SIZES = list(SIZE_MAP.keys())

class MaterialWidget(pn.reactive.ReactiveHTML):
    _template = """<div id="component" class="pnc-component"></div>"""

    __javascript__ = [
        "https://unpkg.com/react@17.0.2/umd/react.production.min.js",
        "https://unpkg.com/react-dom@17.0.2/umd/react-dom.production.min.js",
        "https://unpkg.com/@material-ui/core@4.12.3/umd/material-ui.development.js",
    ]

    __css__ = [
        "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap",
        "https://fonts.googleapis.com/icon?family=Material+Icons",
    ]

    @staticmethod
    def _scripts(element, properties, events, children):
        widget = ReactGenerator(
            element=element,
            properties=properties,
            events=events,
            children=children,
        )
        widget_with_tooltip = ReactGenerator(
            element="MaterialUI.Tooltip",
            properties={"title": "tooltip", "placement": "tooltip_placement"},
            children=[widget],
        )
        return widget_with_tooltip.scripts
class MaterialButton(MaterialWidget, ButtonBase):
    variant = param.Selector(
        default="outlined", objects=["contained", "outlined", "text", "string"]
    )
    color = param.Selector(
        default="primary",
        objects=[
            "inherit",
            "primary",
            "secondary",
            "success",
            "error",
            "info",
            "warning",
            "string",
        ],
    )
    size = param.Selector(default="small", objects=SIZES)
    disable_elevation = param.Boolean()
    disable_focusRipple = param.Boolean()
    disable_ripple = param.Boolean()

    tooltip = param.String("Click Me!")
    tooltip_placement = param.Selector(default="bottom", objects=TOOLTIP_PLACEMENTS)

    width = param.Integer(default=300, bounds=(0, None))
    full_width = param.Boolean(default=True)

    height = param.Integer(default=36, bounds=(0, None))

    _scripts=MaterialWidget._scripts(
        element="MaterialUI.Button",
        properties={
            # Widget
            "autofocus": "autofocus",
            "className": "_css_names",
            "disabled": "disabled",
            # MaterialButton
            "color": "color",
            "disableElevation": "disable_elevation",
            "disableFocusRipple": "disable_focus_ripple",
            "disableRipple": "disable_ripple",
            "fullWidth": "full_width",
            "size": "size",
            "variant": "variant",
        },
        events={
            "onClick": "data.clicks += 1"
        },
        children=["data.name"]
    )

    def __init__(self, **params):
        super().__init__(**params)

        self._handle_size_changed()

    def _handle_button_type_changed(self, event=None):
        if event:
            button_type = event.new
        else:
            button_type = self.button_type
        config = BUTTON_TYPE_MAP[button_type]
        self.variant = config.variant
        self.color = config.color

    @param.depends("size", watch=True)
    def _handle_size_changed(self):
        self.height = SIZE_MAP[self.size]

material_button = MaterialButton(name="click")
pn.Column(material_button, material_button.controls()).servable()