Skip to content

Commit

Permalink
Improve the FileDownload widget (#1306)
Browse files Browse the repository at this point in the history
* Fix filename updating

* Monitor when _transfers() runs

* Fix various issues with the filedownload widget

* Fix the FileDownload docs
  • Loading branch information
maximlt authored and philippjfr committed May 24, 2020
1 parent 371cd44 commit c10117c
Show file tree
Hide file tree
Showing 6 changed files with 167 additions and 28 deletions.
9 changes: 5 additions & 4 deletions examples/reference/widgets/FileDownload.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"The ``FileDownload`` widget allows downloading a file on the frontend by sending the file data to the browser either on initialization (if ``embed=True``) or when the button is clicked. If the button\n",
"The ``FileDownload`` widget allows downloading a file on the frontend by sending the file data to the browser either on initialization (if ``embed=True``) or when the button is clicked.\n",
"\n",
"For more information about listening to widget events and laying out widgets refer to the [widgets user guide](../../user_guide/Widgets.ipynb). Alternatively you can learn how to build GUIs by declaring parameters independently of any specific widgets in the [param user guide](../../user_guide/Param.ipynb). To express interactivity entirely using Javascript without the need for a Python server take a look at the [links user guide](../../user_guide/Param.ipynb).\n",
"\n",
Expand Down Expand Up @@ -43,7 +43,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"The `FileDownload` widget accepts a path to a file or a file-like object (with a `.read` method) if the latter is provided a `filename` must also be set. By default the file is only transferred to the browser after clicking the button is clicked (this requires a live-server or notebook kernel):"
"The `FileDownload` widget accepts a path to a file or a file-like object (with a `.read` method) if the latter is provided a `filename` must also be set. By default (`auto=True` and `embed=False`) the file is only transferred to the browser after the button is clicked (this requires a live-server or notebook kernel):"
]
},
{
Expand All @@ -61,7 +61,7 @@
"cell_type": "markdown",
"metadata": {},
"source": [
"The file data may also be embedded immediately using embed parameter, this allows using the widget even in a static export:"
"The file data may also be embedded immediately using `embed` parameter, this allows using the widget even in a static export:"
]
},
{
Expand Down Expand Up @@ -128,8 +128,9 @@
"metadata": {},
"outputs": [],
"source": [
"years_options = list(autompg.yr.unique())\n",
"years = pn.widgets.MultiChoice(\n",
" name='Years', options=list(autompg.yr.unique()), margin=(0, 20, 0, 0)\n",
" name='Years', options=years_options, value=[years_options[0]], margin=(0, 20, 0, 0)\n",
")\n",
"mpg = pn.widgets.RangeSlider(\n",
" name='Mile per Gallon', start=autompg.mpg.min(), end=autompg.mpg.max()\n",
Expand Down
1 change: 1 addition & 0 deletions examples/user_guide/Widgets.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
"\n",
"* **[``Button``](../reference/widgets/Button.ipynb)**: Allows triggering events when the button is clicked. Unlike other widgets, it does not have a ``value`` parameter.\n",
"* **[``DataFrame``](../reference/widgets/DataFrame.ipynb)**: A widget that allows displaying and editing a Pandas DataFrame.\n",
"* **[``FileDownload``](../reference/widgets/FileDownload.ipynb)**: A button that allows downloading a file on the frontend by sending the file data to the browser.\n",
"* **[``Progress``](../reference/widgets/Progress.ipynb)**: A Progress bar which allows updating current the progress towards some goal or indicate an ongoing process."
]
}
Expand Down
144 changes: 120 additions & 24 deletions panel/models/file_download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,48 +27,142 @@ function dataURItoBlob(dataURI: string) {

export class FileDownloadView extends InputWidgetView {
model: FileDownload

anchor_el: HTMLAnchorElement

_initialized: boolean = false
_downloadable: boolean = false
_click_listener: any
_embed: boolean = false
_prev_href: string | null = ""
_prev_download: string | null = ""

initialize(): void {
super.initialize()
if ( this.model.data && this.model.filename ) {
this._embed = true
}
}

connect_signals(): void {
super.connect_signals()
this.connect(this.model.properties.button_type.change, () => this.render())
this.connect(this.model.properties.data.change, () => this.render())
this.connect(this.model.properties.filename.change, () => this.render())
this.connect(this.model.properties.button_type.change, () => this._update_button_style())
this.connect(this.model.properties.filename.change, () => this._update_download())
this.connect(this.model.properties._transfers.change, () => this._handle_click())
this.connect(this.model.properties.label.change, () => this._update_label())
}

render(): void {
super.render()
// Create an anchor HTML element that is styled as a bokeh button.
// When its 'href' and 'download' attributes are set, it's a downloadable link:
// * A click triggers a download
// * A right click allows to "Save as" the file

// There are three main cases:
// 1. embed=True: The widget is a download link
// 2. auto=False: The widget is first a button and becomes a download link after the first click
// 3. auto=True: The widget is a button, i.e right click to "Save as..." won't work
this.anchor_el = document.createElement('a')
this.anchor_el.classList.add(bk_btn)
this.anchor_el.classList.add(bk_btn_type(this.model.button_type))
this.anchor_el.textContent = this.model.label
if (this.model.data === null || this.model.filename === null) {
this.anchor_el.addEventListener("click", () => this.click())
this.group_el.appendChild(this.anchor_el)
this._initialized = true
return
this._update_button_style()
this._update_label()

// Changing the disabled property calls render() so it needs to be handled here.
// This callback is inherited from ControlView in bokehjs.
if ( this.model.disabled ) {
this.anchor_el.setAttribute("disabled", "")
this._downloadable = false
} else {
this.anchor_el.removeAttribute("disabled")
// auto=False + toggle Disabled ==> Needs to reset the link as it was.
if ( this._prev_download ) {
this.anchor_el.download = this._prev_download
}
if ( this._prev_href ) {
this.anchor_el.href = this._prev_href
}
if ( this.anchor_el.download && this.anchor_el.download ) {
this._downloadable = true
}
}

// If embedded the button is just a download link.
// Otherwise clicks will be handled by the code itself, allowing for more interactivity.
if ( this._embed ) {
this._make_link_downloadable()
} else {
// Add a "click" listener, note that it's not going to
// handle right clicks (they won't increment 'clicks')
this._click_listener = this._increment_clicks.bind(this)
this.anchor_el.addEventListener("click", this._click_listener)
}
const blob = dataURItoBlob(this.model.data)
const uriContent = (URL as any).createObjectURL(blob)
this.anchor_el.href = uriContent
this.anchor_el.download = this.model.filename
//this.group_el.classList.add(bk_btn_group)
this.group_el.appendChild(this.anchor_el)
if (this.model.auto && this._initialized)
}

_increment_clicks() : void {
this.model.clicks = this.model.clicks + 1
}

_handle_click() : void {

// When auto=False the button becomes a link which no longer
// requires being updated.
if ( !this.model.auto && this._downloadable) {
return
}

this._make_link_downloadable()

if ( !this._embed && this.model.auto ) {
// Temporarily removing the event listener to emulate a click
// event on the anchor link which will trigger a download.
this.anchor_el.removeEventListener("click", this._click_listener)
this.anchor_el.click()
this._initialized = true

// In this case #3 the widget is not a link so these attributes are removed.
this.anchor_el.removeAttribute("href")
this.anchor_el.removeAttribute("download")

this.anchor_el.addEventListener("click", this._click_listener)
}

// Store the current state for handling changes of the disabled property.
this._prev_href = this.anchor_el.getAttribute("href")
this._prev_download = this.anchor_el.getAttribute("download")
}

_make_link_downloadable() : void {
this._update_href()
this._update_download()
if ( this.anchor_el.download && this.anchor_el.href ){
this._downloadable = true
}
}

_update_href() : void {
if ( this.model.data ) {
const blob = dataURItoBlob(this.model.data)
this.anchor_el.href = (URL as any).createObjectURL(blob)
}
}

_update_download() : void {
if ( this.model.filename ) {
this.anchor_el.download = this.model.filename
}
}

_update_label(): void {
this.anchor_el.textContent = this.model.label
}

click(): void {
this.model.clicks = this.model.clicks + 1
_update_button_style(): void{
if ( !this.anchor_el.hasAttribute("class") ){ // When the widget is rendered.
this.anchor_el.classList.add(bk_btn)
this.anchor_el.classList.add(bk_btn_type(this.model.button_type))
} else { // When the button type is changed.
const prev_button_type = this.anchor_el.classList.item(1)
if ( prev_button_type ) {
this.anchor_el.classList.replace(prev_button_type, bk_btn_type(this.model.button_type))
}
}
}
}

Expand All @@ -82,6 +176,7 @@ export namespace FileDownload {
data: p.Property<string | null>
label: p.Property<string>
filename: p.Property<string | null>
_transfers: p.Property<number>
}
}

Expand All @@ -106,6 +201,7 @@ export class FileDownload extends InputWidget {
label: [ p.String, "Download" ],
filename: [ p.String, null ],
button_type: [ p.ButtonType, "default" ], // TODO (bev)
_transfers: [ p.Number, 0 ],
})

this.override({
Expand Down
4 changes: 4 additions & 0 deletions panel/models/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,8 @@ class FileDownload(InputWidget):

filename = String(help="""Filename to use on download""")

_transfers = Int(0, help="""
A private property to create and click the link.
""")

title = Override(default='')
29 changes: 29 additions & 0 deletions panel/tests/widgets/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,24 @@ def test_file_download_label():
assert file_download.label == 'Download abc.py'


def test_file_download_filename(tmpdir):
file_download = FileDownload()

filepath = tmpdir.join("foo.txt")
filepath.write("content")
file_download.file = str(filepath)

assert file_download.filename == "foo.txt"

file_download._clicks += 1
file_download.file = __file__

assert file_download.filename == "test_misc.py"

file_download.file = StringIO("data")
assert file_download.filename == "test_misc.py"


def test_file_download_file():
with pytest.raises(ValueError):
FileDownload(StringIO("data"))
Expand Down Expand Up @@ -118,6 +136,17 @@ def cb():
assert file_download.filename == "cba.py"
assert file_download.label == "Download cba.py"


def test_file_download_transfers():
file_download = FileDownload(__file__, embed=True)
assert file_download._transfers == 1

file_download = FileDownload(__file__)
assert file_download._transfers == 0
file_download._clicks += 1
assert file_download._transfers == 1


def test_file_download_data():
file_download = FileDownload(__file__, embed=True)

Expand Down
8 changes: 8 additions & 0 deletions panel/widgets/misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ class FileDownload(Widget):

_clicks = param.Integer(default=0)

_transfers = param.Integer(default=0)

_mime_types = {
'application': {
'pdf': 'pdf', 'zip': 'zip'
Expand Down Expand Up @@ -230,6 +232,11 @@ def __init__(self, file=None, **params):
def _update_default(self):
self._default_label = False

@param.depends('file', watch=True)
def _update_filename(self):
if isinstance(self.file, str):
self.filename = os.path.basename(self.file)

@param.depends('auto', 'file', 'filename', watch=True)
def _update_label(self):
label = 'Download' if self._synced or self.auto else 'Transfer'
Expand Down Expand Up @@ -300,3 +307,4 @@ def _transfer(self):

self.param.set_param(data=data, filename=filename)
self._update_label()
self._transfers += 1

0 comments on commit c10117c

Please sign in to comment.