nteract / vdom

🎄 Virtual DOM for Python
https://github.com/nteract/vdom/blob/master/docs/mimetype-spec.md
BSD 3-Clause "New" or "Revised" License
222 stars 35 forks source link

Add a top-level importSource attribute to VDOM components #100

Open rmorshea opened 5 years ago

rmorshea commented 5 years ago

Summary

I think it would be useful to include a top-level importSource attribute to the VDOM spec:

{
    "importSource": {
        "source": string,
        "fallback": string,
    }
}

The purpose of this attribute is to include ReactJS components in your VDOM that you write or import. You can see this demonstrated in the "ReactJS Components" example from my project idom:

https://idom-sandbox.herokuapp.com/client/index.html

How it Should Work

The source string should be JSX that, when eval'd, will return a component. The attributes and children from the rest of the VDOM will then be passed to this component. For example, the following model:

jsx = """
function Button({ children }) {
    return <button>{ children... }</button>
}
"""

vdom = {
    "children": ["click me!"],
    "importSource": {
        "source": jsx,
        "fallback": "loading...",
    }
}

should result in the the HTML below:

<button>click me!</button>

Importing Other Libraries

You should also be able to return a promise from the JSX that results in a component (hence the presence of the fallback key in the proposed importSource dict):

jsx="""
import('the-package').then(pkg => pkg.default);
"""

Things to Think About

What if the JSX were like a module, and the tagName referred to a member of the exported object (this is similar to what I'm doing in iDOM right now):

jsx = """
function Button({ children }) {
    return <button>{ children... }</button>
}
export default {
    SimpleButton: Button
}
"""

vdom = {
    "tagName": "SimpleButton",
    "children": ["click me!"],
    "importSource": {
        "source": jsx,
        "fallback": "loading...",
    }
}

The idea behind this approach is that you could create an API for JSX that looks a bit like this:

jsx_module = JsxModule(source)

button = jsx_module.SimpleButton("click me!")
something_else = jsx_module.SomeOtherComponent(...)
gnestor commented 5 years ago

I agree that it would be great to be able to use vdom to render React components in addition to native HTML elements. However, I prefer the following interface:

from vdom import import_component

CanvasDraw = import_component('react-canvas-draw')

CanvasDraw(width='100%')

I don't think that we want to encourage people to write Javascript in Python. The above approach allows people to reference Javascript from Python (in this case its referencing react-canvas-draw on npm but they can also reference local Javascript served from the Jupyter server, e.g. import_component('/js/myLib/index.js')).

Additionally, the import_component function is inspired by vdom's create_component function which is used to create a component that vdom doesn't provide a helper function for (e.g. select). This above approach provides a consistent interface for working with native HTML elements and React components.

gnestor commented 5 years ago

I have created an example notebook and a branch of jupyterlab (that patches @nteract/transform-vdom to support import_component) that demonstrates how this approach would work: https://mybinder.org/v2/gh/gnestor/jupyterlab/vdom-react-demo?urlpath=lab/tree/packages/vdom-extension/notebooks/vdom-react.ipynb

rmorshea commented 5 years ago

@gnestor I agree that import_component('react-canvas-draw') is the ideal interface, but by using javascript in the underlying model it allows for greater flexibility while still enabling you to write higher level functions that are much simpler:

def import_component(module):
    return {'importSource': {'source': f"import('https://dev.jspm.io/{module})"}}

IDOM profides a similar interface. You should be able to paste the following code into the IDOM editor and see the canvas:

import idom

Canvas = idom.Import("react-canvas-draw")

Canvas()
gnestor commented 5 years ago

by using javascript in the underlying model it allows for greater flexibility while still enabling you to write higher level functions that are much simpler:

I agree that there's a need for more expressiveness than just providing to an npm module name. Allow me to clarify what I mean by "reference local Javascript served from the Jupyter server, e.g. import_component('/js/myLib/index.js')":

You can create a JS file in JupyterLab (for example) that imports some npm modules and exports a higher-level component. I have an example in the binder above that looks like:

// ReactPianoComponent.js
// Written using vanilla JS vs. JSX so that it can be imported by vdom without requiring transpilation

import React from '//dev.jspm.io/react';
import PianoDefault from '//dev.jspm.io/react-piano-component';

const { default: Piano } = PianoDefault;

function PianoContainer({ children }) {
  return React.createElement(
    'div',
    {
      className: 'interactive-piano__piano-container',
      onMouseDown: event => event.preventDefault()
    },
    children
  );
}

function AccidentalKey({ isPlaying, text, eventHandlers }) {
  return React.createElement(
    'div',
    { className: 'interactive-piano__accidental-key__wrapper' },
    React.createElement(
      'button',
      {
        className: `interactive-piano__accidental-key ${
          isPlaying ? 'interactive-piano__accidental-key--playing' : ''
        }`,
        ...eventHandlers
      },
      React.createElement('div', { className: 'interactive-piano__text' }, text)
    )
  );
}

function NaturalKey({ isPlaying, text, eventHandlers }) {
  return React.createElement(
    'button',
    {
      className: `interactive-piano__natural-key ${
        isPlaying ? 'interactive-piano__natural-key--playing' : ''
      }`,
      ...eventHandlers
    },
    React.createElement('div', { className: 'interactive-piano__text' }, text)
  );
}

function PianoKey({
  isNoteAccidental,
  isNotePlaying,
  startPlayingNote,
  stopPlayingNote,
  keyboardShortcuts
}) {
  function handleMouseEnter(event) {
    if (event.buttons) {
      startPlayingNote();
    }
  }

  const KeyComponent = isNoteAccidental ? AccidentalKey : NaturalKey;
  const eventHandlers = {
    onMouseDown: startPlayingNote,
    onMouseEnter: handleMouseEnter,
    onTouchStart: startPlayingNote,
    onMouseUp: stopPlayingNote,
    onMouseOut: stopPlayingNote,
    onTouchEnd: stopPlayingNote
  };
  return React.createElement(KeyComponent, {
    isPlaying: isNotePlaying,
    text: keyboardShortcuts.join(' / '),
    eventHandlers: eventHandlers
  });
}

export default function InteractivePiano() {
  return React.createElement(
    PianoContainer,
    null,
    React.createElement(Piano, {
      startNote: 'C4',
      endNote: 'B5',
      renderPianoKey: PianoKey,
      keyboardMap: {
        Q: 'C4',
        2: 'C#4',
        W: 'D4',
        3: 'D#4',
        E: 'E4',
        R: 'F4',
        5: 'F#4',
        T: 'G4',
        6: 'G#4',
        Y: 'A4',
        7: 'A#4',
        U: 'B4',
        V: 'C5',
        G: 'C#5',
        B: 'D5',
        H: 'D#5',
        N: 'E5',
        M: 'F5',
        K: 'F#5',
        ',': 'G5',
        L: 'G#5',
        '.': 'A5',
        ';': 'A#5',
        '/': 'B5'
      }
    })
  );
}

This is an example of a React component that requires some callback props that you can't provide via vdom (e.g. Python callbacks), so we need to provide a higher-level component that provides these props in JS.

Now from your notebook, we can write:

Piano = import_component('./ReactPianoComponent')

Piano()

This allows us to accomplish the same result as using importSource while separating Python and Javascript in a reasonable way (using files).

rmorshea commented 5 years ago

Is there some way that we could still transpile JSX client-side? I just don't see anyone wanting to write vanilla JS for React. Even if you did, you'd probably end up re-writing it in JSX once you turned it into a real NPM package.

gnestor commented 5 years ago

Ya, vdom could transpile any files passed to import_component, however if that file imports any other files (using ES modules), then those would not be transpiled. Webpack and rollup do that kind of stuff (transpiling all dependencies and bundling them) but I don't think that babel (which does the transpiling) knows how to do that.

I did a little searching and I think the solution to using JSX in pure ES6 is template literals:

import { React, ReactDOM } from ‘//unpkg.com/es-react';
import htm from ‘//unpkg.com/htm'
const html = htm.bind(React.createElement)

class App extends React.Component {
  render() {
    return html`
      <div>
        App goes here
      </div>
    `;
  }
}

ReactDOM.render(html`<${App} />`, document.body);
rmorshea commented 5 years ago

@gnestor the template literals definitely are better.

Perhaps transpiling could be optional? Maybe a field in importSource could tell the client how to transpile (if at all)? Not sure what information would need to be provided to enable that though.

rmorshea commented 5 years ago

Transpiling could be a later addition though. For now, I think we can suggest using htm.

gnestor commented 5 years ago

Agreed 👍

rmorshea commented 4 years ago

This is implemented in IDOM now: https://idom.readthedocs.io/en/latest/javascript-modules.html