Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion _quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ website:
- section: "In Depth"
contents:
- docs/ipywidgets.qmd
- docs/custom-components.qmd
- docs/ui-html.qmd
- docs/workflow-server.qmd
- section: "Framework Comparisons"
Expand All @@ -152,7 +153,6 @@ format:
code-copy: true
link-external-newwindow: true


editor:
markdown:
wrap: sentence
Binary file added docs/custom-components-result.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
314 changes: 314 additions & 0 deletions docs/custom-components.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
---
title: "Custom Javascript components"
---

In this post, you will learn how to create a custom element and accompanying output binding in Shiny. This is useful if you want to create an output that is not currently in Shiny for your app or if you want to create a custom output for a package you are developing.

:::{.callout-note}
The code shown here is simplified to get the point across, but before you use it in your own app, you should make sure to add error handling and other features to make it robust.
:::

# The problem

You found a new table library that you really want to use in your Shiny app. Problem is, there's no wrapper for it, currently. The library is [Tabulator](https://tabulator.info/) and it's a javascript library for making tables with data.

# The solution

To implement a custom tabulator element for your app, you'll need to write three things:

1. A javascript script that renders the element on the client side using the Tabulator library
2. An `output_tabulator()` function for placing the element in your app's UI
3. A `render_tabulator()` decorator for passing table data to the javascript code rendering the element on the server side

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be nice here to have a mermaid diagram of how all the components work together and what they do.


## The javascript code

First things first, to use a custom Javascript library we need to write... some javascript.

To do this we will create a new folder called `tabulator/` that has the following structure:

```
tabulator/
tabulator_esm.min.js
tabulator.min.css
tableComponent.js
```

Both `tabulator_esm.min.js` and `tabulator.min.css` are downloaded from [tabulator's website.](https://tabulator.info/docs/5.5/install#sources-download) `tableComponent.js` is the script that we will write that contains the code for rendering the table to our Shiny app.

:::{.callout-note}
The code in this article will be abbreviated to show the relevant parts. If you want to see the full code, see the [accompanying repo.](https://github.com/nstrayer/pyshiny-output-binding-example)
:::


To create an output binding in Shiny, we create a new instance of the `Shiny.OutputBinding` class.

```javascript
class TabulatorOutputBinding extends Shiny.OutputBinding {
// Find element to render in
find(scope) { ... }

// Render output element in the found element
renderValue(el, payload) { ... }
}

// Register the binding
Shiny.outputBindings.register(
new TabulatorOutputBinding(),
"shiny-tabulator-output"
);
```

This class has two methods that we need to implement: `find()` and `renderValue()`. The `find()` method is used to find the element that we want to render the table in. The `renderValue()` method is used to render the table in the element. After making that class we need to register it with Shiny so it can find and send data to instances of our output.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This class has two methods that we need to implement: `find()` and `renderValue()`. The `find()` method is used to find the element that we want to render the table in. The `renderValue()` method is used to render the table in the element. After making that class we need to register it with Shiny so it can find and send data to instances of our output.
This class has two methods that we need to implement: `find()` and `renderValue()`. The `find()` method is used to identify the element that will contain the rendered table. The `renderValue()` method is used to render the table in the element. After making that class we need to register it with Shiny so it can find and send data to instances of our output.


### The `find()` method

Now that we have the scaffolding setup, we can fill it in. Starting with the `find` method, this function is passed a `scope` object, which is a `JQuery` selection and should return the element you wish to render your output into.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might have a bit too much passive voice, maybe something like :

"Now that we have the scaffolding setup we can start by filling in the find method. This function takes a scope object, which is a JQuery selection that should rereturn the element which will contain the output". I'm not sure if "contains the output" is the same as "render into".


```javascript
class TabulatorOutputBinding extends Shiny.OutputBinding {
find(scope) {
return scope.find(".shiny-tabulator-output");
}

renderValue(el, payload) {...}
}

Shiny.outputBindings.register(...);
```

Note that we're using the class `".shiny-tabulator-output"` here to mark the element that we want to render the table in. This is the same class that we will use in our `output_tabulator()` function in our app's server code. You can use any valid css selector here, but it's common to use a descriptive class name like this.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Note that we're using the class `".shiny-tabulator-output"` here to mark the element that we want to render the table in. This is the same class that we will use in our `output_tabulator()` function in our app's server code. You can use any valid css selector here, but it's common to use a descriptive class name like this.
Note that we're using the class `".shiny-tabulator-output"` here to mark the element that we want to render the table in. This is the same class that we will use in our `output_tabulator()` function in our app's server code. You can use any valid css selector here, but it's common to use a class name which describes the output.


### The `renderValue()` method

Next, we fill in the main logic for rendering our table in to the `renderValue` method. This method gets passed two arguments: `el` which is an HTMLElement as found by our find function, and `payload` which is the data that the server has provided from the render function (more on this [soon.](#the-render_tabulator-decorator))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Next, we fill in the main logic for rendering our table in to the `renderValue` method. This method gets passed two arguments: `el` which is an HTMLElement as found by our find function, and `payload` which is the data that the server has provided from the render function (more on this [soon.](#the-render_tabulator-decorator))
Next, we fill in the main logic for rendering our table in to the `renderValue` method. This method gets passed two arguments: `el` which is an HTMLElement identifies by our find function, and `payload` which is the data that the server has provided from the render function (more on this [soon.](#the-render_tabulator-decorator))


```javascript
// Import the Tabulator library
import { Tabulator } from "./tabulator_esm.min.js";

class TabulatorOutputBinding extends Shiny.OutputBinding {
find(scope) { ... }

renderValue(el, payload) {
// Unpack the info we get from the associated render function
const { columns, data, type_hints } = payload;

// Convert the column names to a format that Tabulator expects
const columnsDef = columns.map((col, i) => {
return {
title: col,
field: col,
hozAlign: type_hints[i] === "numeric" ? "right" : "left",
};
});

// Data comes in as a series of rows with each row having as many elements
// as there are columns in the data. We need to map this to a series of
// objects with keys corresponding to the column names.
function zipRowWithColumns(row) {
const obj = {};
row.forEach((val, i) => {
obj[columns[i]] = val;
});
return obj;
}

// Render the table
new Tabulator(el, {
data: data.map(zipRowWithColumns),
layout: "fitColumns",
columns: columnsDef,
});
}
}

Shiny.outputBindings.register(...);
```

The implementation of this function is not terribly important and draws directly from the [tabulator docs](https://tabulator.info/docs/5.5/quickstart).

What matters is that we take our data, transform it in some way, and then instantiate our table with the `new Tabulator(el, {...})` call. In this case we take data in the form of the rows of a passed data frame, the column names, and the types of those columns (we decide this format when we [create the render decorator](#the-render_tabulator-decorator)), and construct a js object in the form of `data = [{col1: foo1, col2: bar1, ...}, {col1: foo2, col2: bar2, ...}]`. We also combine the column names and types to create the `columnsDef` object that Tabulator expects.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
What matters is that we take our data, transform it in some way, and then instantiate our table with the `new Tabulator(el, {...})` call. In this case we take data in the form of the rows of a passed data frame, the column names, and the types of those columns (we decide this format when we [create the render decorator](#the-render_tabulator-decorator)), and construct a js object in the form of `data = [{col1: foo1, col2: bar1, ...}, {col1: foo2, col2: bar2, ...}]`. We also combine the column names and types to create the `columnsDef` object that Tabulator expects.
What matters is that we take our data, transform it in some way, and then instantiate our table with the `new Tabulator(el, {...})` call. In this case we take data in the form of the rows of a passed data frame, the column names, and the types of those columns (this is determined by the [the render decorator](#the-render_tabulator-decorator)), and construct a js object in the form of `data = [{col1: foo1, col2: bar1, ...}, {col1: foo2, col2: bar2, ...}]`. We also combine the column names and types to create the `columnsDef` object that Tabulator expects.


The format of this will vary entirely based upon the type of component you're building though, so if you don't follow, don't worry!
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The format of this will vary entirely based upon the type of component you're building though, so if you don't follow, don't worry!
Don't worry too much about following this particular format because it will change depending on the component that you are wrapping.


:::{.callout-note}

Since this code is relying on the `Shiny` object just existing in the Javascript context. It's safe to wrap all the above code in an if statement so it only runs if that object exists. This is useful if you're writing a package that might be used in a non-Shiny context, your code won't error out and break the document.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Since this code is relying on the `Shiny` object just existing in the Javascript context. It's safe to wrap all the above code in an if statement so it only runs if that object exists. This is useful if you're writing a package that might be used in a non-Shiny context, your code won't error out and break the document.
Since this code is relying on the `Shiny` object existing in the Javascript context. It's safe to wrap all the above code in an if statement so it only runs if that object exists. This is useful if you're writing a package that might be used in a non-Shiny context because your code won't error out and break the document.


```javascript
if (Shiny) {
class TabulatorOutputBinding extends Shiny.OutputBinding { ... }

Shiny.outputBindings.register(...);
}
```
:::

To see the full javascript code, see `tabulator/tableComponent.js` in the [accompanying repo.](https://github.com/nstrayer/pyshiny-output-binding-example/blob/main/tabulator/tableComponent.js)

## The `output_tabulator()` function

For the case of our table we just need an HTML element to target with our javascript code. When we setup [the find method](#the-find-method) for our binding, we chose the class `shiny-tabulator-output` as the mark of a tabualtor output, so we need to add that class. We also need to allow the user to set the ID of the element so that Shiny knows which element to target with which output. By wrapping the `id` argument in `resolve_id()` we make sure it will work in the context of modules. We'll also add a height argument so that the user can set the height of the table.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
For the case of our table we just need an HTML element to target with our javascript code. When we setup [the find method](#the-find-method) for our binding, we chose the class `shiny-tabulator-output` as the mark of a tabualtor output, so we need to add that class. We also need to allow the user to set the ID of the element so that Shiny knows which element to target with which output. By wrapping the `id` argument in `resolve_id()` we make sure it will work in the context of modules. We'll also add a height argument so that the user can set the height of the table.
Next we need an HTML element to target with our javascript code. When we setup [the find method](#the-find-method) for our binding, we chose the class `shiny-tabulator-output` as the mark of a tabualtor output, so we need to add that class. We also need to allow the user to set the ID of the element so that Shiny knows which element to target with which output. By wrapping the `id` argument in `resolve_id()` we make sure it will work in the context of modules. We'll also add a height argument so that the user can set the height of the table.


```python
from shiny import ui, App
from shiny.module import resolve_id

from htmltools import HTMLDependency

tabulator_dep = HTMLDependency(
"tabulator",
"5.5.2",
source={"subdir": "tabulator"},
script={"src": "tableComponent.js", "type": "module"},
stylesheet={"href": "tabulator.min.css"},
all_files=True,
)

def output_tabulator(id, height="200px"):
return ui.div(
tabulator_dep,
# Use resolve_id so that our component will work in a module
id=resolve_id(id),
class_="shiny-tabulator-output",
style=f"height: {height}",
)
```

We use the `HTMLDependency` function to bind up the assets needed for tabulator that we made in the previous step and making sure that they're included in our app anytime the `output_tabulator()` function is called (but not more than once.)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can include this in the callout


:::{.callout-note}
Note the use of `all_files=True` here. This makes it so we can do the esm import of the tabulator library. Otherwise `tabulator_esm.min.js` would not be hosted and the js library couldn't find it.
:::

Now, the `output_tabulator()` function can be called anywhere we want to render a table in our app.

## The `render_tabulator()` decorator

Now we've got the client-side logic finished, we need to write a custom render decorator that sends our data into the component.

To do this we can leverage some tools provided by Shiny in the `shiny.render.transformer` subpackage.

```python
from shiny.render.transformer import (
output_transformer,
resolve_value_fn,
TransformerMetadata,
ValueFn,
)


@output_transformer
async def render_tabulator(
_meta: TransformerMetadata,
_fn: ValueFn[pd.DataFrame | None],
):
res = await resolve_value_fn(_fn)
if res is None:
return None

if not isinstance(res, pd.DataFrame):
# Throw an error if the value is not a dataframe
raise TypeError(f"Expected a pandas.DataFrame, got {type(res)}. ")

# Get data from dataframe as a list of lists where each inner list is a
# row, column names as array of strings and types of each column as an
# array of strings
return {
"data": res.values.tolist(),
"columns": res.columns.tolist(),
"type_hints": res.dtypes.astype(str).tolist(),
}
```

The `output_transformer` decorator is a decorator factory that takes a function that returns a dictionary of data to be passed to the client side. The function that it decorates is passed two arguments: `_meta` and `_fn`.

`_meta` is a dictionary of metadata about the function that is being decorated. We don't use it in our example.

`_fn` is the function that is being decorated. Aka the function that goes below the `@render_tabulator()` in your app's server code. In this case we are expecting that that function returns either a pandas dataframe or `None`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`_fn` is the function that is being decorated. Aka the function that goes below the `@render_tabulator()` in your app's server code. In this case we are expecting that that function returns either a pandas dataframe or `None`.
`_fn` is the decorated function which appears below the `@render_tabulator()` decorator in your app's server code. In this case we are expecting the function to return either a pandas dataframe or `None`.


In the code above we use types so that we can get some type checking in our IDE, but these are not required. Also note that the decorated function is an async function, so we need to use the `await` keyword when we call it for `resolve_value_fn()`.

```python
...
res = await resolve_value_fn(_fn)
...
```

`resolve_value_fn()` is a helper provided in `shiny.render.transformer` for resolving the value of a function that may or may not be async. This allows us to write our code in a way that is agnostic to how the user has written their render function.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`resolve_value_fn()` is a helper provided in `shiny.render.transformer` for resolving the value of a function that may or may not be async. This allows us to write our code in a way that is agnostic to how the user has written their render function.
`resolve_value_fn()` is a helper provided in `shiny.render.transformer` for resolving the value of a function that may or may not be async. This ensures that the function will work whether or not the end user has defined their render function asynchronously.


Next, we check to make sure that the value returned by the function is a dataframe. If it's not, we throw an error. This is not required, but it's good practice to do so.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Next, we check to make sure that the value returned by the function is a dataframe. If it's not, we throw an error. This is not required, but it's good practice to do so.
Next, we check to make sure that the value returned by the function is a dataframe. If it's not, we throw an error. This is not strictly required, but is a best practice.


```python
...
if not isinstance(res, pd.DataFrame):
# Throw an error if the value is not a dataframe
raise TypeError(f"Expected a pandas.DataFrame, got {type(res)}. ")
...
```

Finally, we return a dictionary of data that we want to pass to the client side. In this case we return the data as a list of lists, the column names as an array of strings, and the types of each column as an array of strings using methods provided by pandas.

```python
...
return {
"data": res.values.tolist(),
"columns": res.columns.tolist(),
"type_hints": res.dtypes.astype(str).tolist(),
}
...
```

This returned value is then what gets sent to the client side and is available in the `payload` argument of the `renderValue()` method of our `TabulatorOutputBinding` class.

# The result

Now we have all the components neccesary to use our tabulator output component. Here's an app that uses it to render some number of rows of the indomitable `mtcars` dataset.

```python
from shiny import ui, App
from pathlib import Path
import pandas as pd

# Code for the custom output
...

# App code
app_ui = ui.page_fluid(
ui.input_slider("n", "Number of rows to show", 1, 20, 10),
output_tabulator("tabulatorTable"),
)


def server(input, output, session):
@render_tabulator
def tabulatorTable():
return pd.read_csv(Path(__file__).parent / "mtcars.csv").head(input.n())


app = App(app_ui, server)
```

Which results in the following app:


<style>
/*
Co-opt the sourceCode class for nice dropshadow to
separate screenshot from background
*/
.sourceCode:has(.app-screenshot) {
padding:0;
}
.sourceCode:has(.app-screenshot) > p {
margin:0;
}
</style>

:::{.sourceCode}
![](./custom-components-result.png){fig-alt="Screenshot of resulting app" class="app-screenshot"}
:::


To see the full app script, see `app.py` in the accompanying repo for this post [here.](https://github.com/nstrayer/pyshiny-output-binding-example/blob/main/app.py)