From e80b065a58483d2a021cab464d7295ca8437d3c8 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Tue, 8 Jan 2019 19:11:38 +0000 Subject: [PATCH 1/4] Add FileInput widget --- panel/models/fileinput.ts | 70 +++++++++++++++++++++++++++++++++++++++ panel/models/widgets.py | 11 +++++- panel/widgets.py | 51 +++++++++++++++++++--------- 3 files changed, 116 insertions(+), 16 deletions(-) create mode 100644 panel/models/fileinput.ts diff --git a/panel/models/fileinput.ts b/panel/models/fileinput.ts new file mode 100644 index 0000000000..ee793f538a --- /dev/null +++ b/panel/models/fileinput.ts @@ -0,0 +1,70 @@ +import * as p from "core/properties" +import {div} from "core/dom" +import {Widget, WidgetView} from "models/widgets/widget" + +export class FileInputView extends WidgetView { + model: FileInput + + protected dialogEl: HTMLElement + + initialize(options: any): void { + super.initialize(options) + this.render() + } + + connect_signals(): void { + super.connect_signals() + this.connect(this.model.change, () => this.render()) + this.connect(this.model.properties.value.change, () => this.render()) + this.connect(this.model.properties.width.change, () => this.render()) + } + + render(): void { + if (this.dialogEl) { + return + } + this.dialogEl = document.createElement('input') + this.dialogEl.type = "file"; + this.dialogEl.multiple = false; + this.dialogEl.style = `width:{this.model.width}px`; + this.dialogEl.onchange = (e) => this.load_file(e); + this.el.appendChild(this.dialogEl) + } + + load_file(e: any): void { + const reader = new FileReader(); + reader.onload = (e) => this.set_value(e) + reader.readAsDataURL(e.target.files[0]) + } + + set_value(e: any): void { + this.model.value = e.target.result; + } +} + +export namespace FileInput { + export interface Attrs extends Widget.Attrs {} + export interface Props extends Widget.Props {} +} + +export interface FileInput extends FileInput.Attrs {} + +export abstract class FileInput extends Widget { + + properties: FileInput.Props + + constructor(attrs?: Partial) { + super(attrs) + } + + static initClass(): void { + this.prototype.type = "FileInput" + this.prototype.default_view = FileInputView + + this.define({ + value: [ p.Any, '' ], + }) + } +} + +FileInput.initClass() diff --git a/panel/models/widgets.py b/panel/models/widgets.py index 5c76577086..c4d3e08bf3 100644 --- a/panel/models/widgets.py +++ b/panel/models/widgets.py @@ -1,6 +1,6 @@ import os -from bokeh.core.properties import Int, Override, Enum +from bokeh.core.properties import Int, Override, Enum, String from bokeh.models import Widget from ..util import CUSTOM_MODELS @@ -32,4 +32,13 @@ class Player(Widget): height = Override(default=250) +class FileInput(Widget): + + __implementation__ = os.path.join(os.path.dirname(__file__), 'fileinput.ts') + + value = String(help="Selected file") + + CUSTOM_MODELS['panel.models.widgets.Player'] = Player +CUSTOM_MODELS['panel.models.widgets.FileInput'] = FileInput + diff --git a/panel/widgets.py b/panel/widgets.py index e260228a29..2e6a554c95 100644 --- a/panel/widgets.py +++ b/panel/widgets.py @@ -6,6 +6,8 @@ import re import ast + +from base64 import b64decode from collections import OrderedDict from datetime import datetime @@ -23,7 +25,7 @@ ) from .layout import Column, Row, Spacer, WidgetBox # noqa -from .models.widgets import Player as _BkPlayer +from .models.widgets import Player as _BkPlayer, FileInput as _BkFileInput from .viewable import Reactive from .util import as_unicode, push, value_as_datetime, hashable @@ -89,6 +91,25 @@ class TextInput(Widget): _widget_type = _BkTextInput +class FileInput(Widget): + + filetype = param.String(default='') + + value = param.Parameter(default='') + + _widget_type = _BkFileInput + + _rename = {'name': None, 'filetype': None} + + def _process_property_change(self, msg): + msg = super(FileInput, self)._process_property_change(msg) + if 'value' in msg: + header, content = msg['value'].split(",", 1) + msg['filetype'] = header.split(':')[1].split(';')[0] + msg['value'] = b64decode(content) + return msg + + class StaticText(Widget): value = param.Parameter(default=None) @@ -429,7 +450,7 @@ def _process_property_change(self, msg): class _RadioGroupBase(Select): - + def _process_param_change(self, msg): msg = super(Select, self)._process_param_change(msg) mapping = OrderedDict([(hashable(v), k) for k, v in self.options.items()]) @@ -448,12 +469,12 @@ def _process_property_change(self, msg): class RadioButtonGroup(_RadioGroupBase): - + _widget_type = _BkRadioButtonGroup class RadioBoxGroup(_RadioGroupBase): - + _widget_type = _BkRadioBoxGroup @@ -479,33 +500,33 @@ def _process_property_change(self, msg): class CheckButtonGroup(_CheckGroupBase): - + _widget_type = _BkCheckboxButtonGroup class CheckBoxGroup(_CheckGroupBase): - + _widget_type = _BkCheckboxGroup - + class ToggleGroup(Select): """This class is a factory of ToggleGroup widgets. - + A ToggleGroup is a group of widgets which can be switched 'on' or 'off'. - + Two types of widgets are available through the widget_type argument : - 'button' (default) - 'box' - + Two different behaviors are available through behavior argument: - 'check' (default) : Any number of widgets can be selected. In this case value is a 'list' of objects - 'radio' : One and only one widget is switched on. In this case value is an 'object' - + """ - + _widgets_type = ['button', 'box'] _behaviors = ['check', 'radio'] - + def __new__(cls, widget_type='button', behavior='check', **params): if widget_type not in ToggleGroup._widgets_type: @@ -514,7 +535,7 @@ def __new__(cls, widget_type='button', behavior='check', **params): if behavior not in ToggleGroup._behaviors: raise ValueError('behavior {} is not valid. Valid options are {}' .format(widget_type, ToggleGroup._behaviors)) - + if behavior is 'check': if widget_type == 'button': return CheckButtonGroup(**params) @@ -543,7 +564,7 @@ class ToggleButtons(CheckButtonGroup): """" Deprecated, use ToggleGroup instead. """ - + def __new__(cls, **params): from warnings import warn warn("Deprecated class, will be removed in future.\nSee ToggleGroup", From 9f2272d6292f7c03c047169f498946a02e31f911 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 9 Jan 2019 14:46:05 +0000 Subject: [PATCH 2/4] Fixed issue syncing FileInput state --- panel/models/widgets.py | 4 ++-- panel/widgets.py | 22 +++++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/panel/models/widgets.py b/panel/models/widgets.py index c4d3e08bf3..fc89aae22b 100644 --- a/panel/models/widgets.py +++ b/panel/models/widgets.py @@ -1,6 +1,6 @@ import os -from bokeh.core.properties import Int, Override, Enum, String +from bokeh.core.properties import Int, Override, Enum, Any from bokeh.models import Widget from ..util import CUSTOM_MODELS @@ -36,7 +36,7 @@ class FileInput(Widget): __implementation__ = os.path.join(os.path.dirname(__file__), 'fileinput.ts') - value = String(help="Selected file") + value = Any(help="Encoded file data") CUSTOM_MODELS['panel.models.widgets.Player'] = Player diff --git a/panel/widgets.py b/panel/widgets.py index 2e6a554c95..f86174c830 100644 --- a/panel/widgets.py +++ b/panel/widgets.py @@ -7,7 +7,7 @@ import re import ast -from base64 import b64decode +from base64 import b64decode, b64encode from collections import OrderedDict from datetime import datetime @@ -93,19 +93,31 @@ class TextInput(Widget): class FileInput(Widget): - filetype = param.String(default='') + mime_type = param.String(default=None) - value = param.Parameter(default='') + value = param.Parameter(default=None) _widget_type = _BkFileInput - _rename = {'name': None, 'filetype': None} + _rename = {'name': None, 'mime_type': None} + + def _process_param_change(self, msg): + msg = super(FileInput, self)._process_param_change(msg) + if 'value' in msg: + if self.mime_type: + template = 'data:{mime};base64,{data}' + data = b64encode(msg['value']) + msg['value'] = template.format(data=data.decode('utf-8'), + mime=self.mime_type) + else: + msg['value'] = '' + return msg def _process_property_change(self, msg): msg = super(FileInput, self)._process_property_change(msg) if 'value' in msg: header, content = msg['value'].split(",", 1) - msg['filetype'] = header.split(':')[1].split(';')[0] + msg['mime_type'] = header.split(':')[1].split(';')[0] msg['value'] = b64decode(content) return msg From 9fc3d70da2438e8fb14cac45d2b2eed57cd01ebe Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 9 Jan 2019 14:46:26 +0000 Subject: [PATCH 3/4] Added FileInput docs --- examples/user_guide/Widgets.ipynb | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/examples/user_guide/Widgets.ipynb b/examples/user_guide/Widgets.ipynb index 8d30a0416b..cb7e576ed6 100644 --- a/examples/user_guide/Widgets.ipynb +++ b/examples/user_guide/Widgets.ipynb @@ -756,12 +756,35 @@ "cross_select.value" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## FileInput" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``FileInput`` widget allows uploading a file from the frontend and makes the file data and file type available in Python." + ] + }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "FileInput()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To read out the content of the file you can access the ``value`` parameter which holds a bytestring containing the files contents. Additionally information about the file type is made available on the ``filetype`` parameter expressed as a MIME type, e.g. ``image/png`` or ``text/csv``." + ] } ], "metadata": { From ee1fc4fcc095a780b3aa7a1734eb3eb6c76e9008 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Wed, 9 Jan 2019 14:46:52 +0000 Subject: [PATCH 4/4] Added FileInput unit test --- panel/tests/test_widgets.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/panel/tests/test_widgets.py b/panel/tests/test_widgets.py index 7107a9005a..90ae322d5a 100644 --- a/panel/tests/test_widgets.py +++ b/panel/tests/test_widgets.py @@ -5,14 +5,14 @@ from bokeh.layouts import WidgetBox from bokeh.models import Div as BkDiv, Slider as BkSlider -from panel.models.widgets import Player as BkPlayer +from panel.models.widgets import Player as BkPlayer, FileInput as BkFileInput from panel.util import block_comm from panel.widgets import ( TextInput, StaticText, FloatSlider, IntSlider, RangeSlider, LiteralInput, Checkbox, Select, MultiSelect, Button, Toggle, DatePicker, DateRangeSlider, DiscreteSlider, DatetimeInput, RadioButtons, ToggleButtons, CrossSelector, DiscretePlayer, - ToggleGroup + ToggleGroup, FileInput ) @@ -663,3 +663,20 @@ def test_discrete_player(document, comm): discrete_player.value = 100 assert widget.value == 3 + + +def test_file_input(document, comm): + file_input = FileInput() + + box = file_input._get_model(document, comm=comm) + + assert isinstance(box, WidgetBox) + + widget = box.children[0] + assert isinstance(widget, BkFileInput) + + file_input._comm_change({'value': 'data:text/plain;base64,U29tZSB0ZXh0Cg=='}) + assert file_input.value == b'Some text\n' + + file_input.param.trigger('value') + assert widget.value == 'data:text/plain;base64,U29tZSB0ZXh0Cg=='