Skip to content

FileUpload Widget #2211

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
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
1 change: 1 addition & 0 deletions ipywidgets/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@
from .widget_layout import Layout
from .widget_media import Image, Video, Audio
from .widget_style import Style
from .widget_upload import FileUpload, MultiFileUpload
37 changes: 37 additions & 0 deletions ipywidgets/widgets/tests/test_widget_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

from unittest import TestCase

from traitlets import TraitError

from ipywidgets import FileUpload, MultiFileUpload


class TestFileUpload(TestCase):

def test_construction(self):
uploader = FileUpload()
# Defaults
assert uploader.accept == ''

def test_construction_accept(self):
uploader = FileUpload(accept='.txt')
assert uploader.accept == '.txt'


class TestMultiFileUpload(TestCase):

def test_construction(self):
uploader = MultiFileUpload()
# Defaults
assert uploader.accept == ''
assert uploader.multiple == True

def test_construction_accept(self):
uploader = MultiFileUpload(accept='.txt')
assert uploader.accept == '.txt'

def test_construction_multiple(self):
uploader = MultiFileUpload(multiple=False)
assert uploader.multiple == False
126 changes: 126 additions & 0 deletions ipywidgets/widgets/widget_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

from .widget_core import CoreWidget
from .widget_description import DescriptionWidget, DescriptionStyle
from .valuewidget import ValueWidget
from .trait_types import TypedTuple
from .widget import register
from traitlets import Unicode, Bool, Dict, List, observe
import base64
import copy
from datetime import datetime

@register()
class FileUpload(DescriptionWidget, ValueWidget, CoreWidget):
"""Upload file from a browser to python

Parameters
----------
values : Dict
File object. A file can have the following fields (it will only ever have one of error or contents set):
name: string
lastModified: datetime
type: string
contents: any
error: string

accept : string
Limit accepted file types. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-accept
"""
_view_name = Unicode('UploadView').tag(sync=True)
_model_name = Unicode('UploadModel').tag(sync=True)

disabled = Bool(False, help="Enable or disable user changes.").tag(sync=True)
value = Dict().tag(sync=False) # We do not sync as user cannot push data
loading = Bool(False, help="Show or hide the loading indicator").tag(sync=True)
# Used for internal transfer. These are then merged into value for users
_values_base64 = List(help='File content, base64 encoded.').tag(trait=Unicode()).tag(sync=True)
_metadata = List(help='File metadata').tag(sync=True)

accept = Unicode(help='Type of files the input accepts. None for all. See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-accept').tag(sync=True)

def __init__(self, accept='', **kwargs):
self.accept = accept
self.loading = False
super(FileUpload, self).__init__(**kwargs)

def clear(self):
_values_base64 = []
_metadata = []

@observe('value')
def _on_value_changed(self, *args):
# If the user clears, we should too
if(len(self.value) == 0):
self.clear()

def _get_value_from_synced_traits(self):
"""
Parse data from JS and put into python friendly form
"""
values = []
loading = False
for idx, val in enumerate(self._metadata):
f = copy.copy(val)
if 'lastModified' in f:
f['lastModified'] = datetime.fromtimestamp(f['lastModified']/1000.0)

if idx < len(self._values_base64) and self._values_base64[idx]:
f['contents'] = base64.b64decode(self._values_base64[idx].split(',', 1)[1])
elif not 'error' in f:
loading = True
values.append(f)

return (values, loading)

def _update_value_and_loading(self):
values, loading = self._get_value_from_synced_traits()

self.value = values[0] if len(values) > 0 else {}

# We do the check here so that the UI shows if the data is in python
if not loading:
self.loading = loading


@observe('_metadata')
def _on_metadata_changed(self, *args):
self._update_value_and_loading()

@observe('_values_base64')
def _on_files_changed(self, *args):
self._update_value_and_loading()


@register()
class MultiFileUpload(FileUpload):
"""Upload file from a browser to python

Parameters
----------
values : TypedTuple
TypedTuple of file objects (Dict). See FileUpload for more details
accept : string
Limit accepted file types. See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-accept
multiple : bool
Allow for multiple files to be uploaded. Defaults to true, but can reuse this for single file uploads with same API as multiple
"""

value = TypedTuple(trait=Dict()).tag(sync=False) # We do not sync as user cannot push data
multiple = Bool(help='If true, allow for multiple input files. Else only accept one').tag(sync=True)

def __init__(self, accept='', multiple=True, **kwargs):
self.accept = accept
self.multiple = multiple
self.loading = False
super(FileUpload, self).__init__(**kwargs)

def _update_value_and_loading(self):
values, loading = self._get_value_from_synced_traits()

self.value = values

# We do the check here so that the UI shows if the data is in python
if not loading:
self.loading = loading
15 changes: 15 additions & 0 deletions packages/controls/css/widgets-base.css
Original file line number Diff line number Diff line change
Expand Up @@ -1074,3 +1074,18 @@
/* Make it possible to have absolutely-positioned elements in the html */
position: relative;
}

/* Upload Widget */
.widget-upload-loader {
border: 5px solid #f3f3f3; /* Light grey */
border-top: 5px solid #3498db; /* Blue */
border-radius: 50%;
width: 12px;
height: 12px;
animation: spin 2s linear infinite;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
1 change: 1 addition & 0 deletions packages/controls/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export * from './widget_selection';
export * from './widget_selectioncontainer';
export * from './widget_string';
export * from './widget_description';
export * from './widget_upload';

export
const version = (require('../package.json') as any).version;
138 changes: 138 additions & 0 deletions packages/controls/src/widget_upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.

import {
CoreDescriptionModel
} from './widget_core';

import {
DescriptionView
} from './widget_description';

import * as _ from 'underscore';

export
class UploadModel extends CoreDescriptionModel {
defaults() {
return _.extend(super.defaults(), {
_model_name: 'UploadModel',
_view_name: 'UploadView',
_values_base64: [],
accept: '',
multiple: false
});
}
}

export
class UploadView extends DescriptionView {
render() {
/**
* Called when view is rendered.
*/
super.render();
this.pWidget.addClass('jupyter-widgets');
this.el.classList.add('widget-inline-hbox');
this.pWidget.addClass('widget-upload');

this.loader = document.createElement('div');
this.loader.classList.add('widget-upload-loader');
this.el.appendChild(this.loader);

this.upload_container = document.createElement('input');
this.upload_container.setAttribute('type', 'file');
this.handleUploadChanged = this.handleUploadChanged.bind(this);
this.upload_container.onchange = this.handleUploadChanged;

this.el.appendChild(this.upload_container);

this.listenTo(this.model, 'change:_metadata', this._metadata_updated);
this._metadata_updated();
this.update(); // Set defaults.
}

_metadata_updated() {
// Only allow for value to clear. This is a rule enforced by browsers
const metadata = this.model.get('_metadata');
if (metadata.length === 0) {
this.upload_container.value = '';
}
}

update(options?) {
/**
* Update the contents of this view
*
* Called when the model is changed. The model may have been
* changed by another view or by a state update from the back-end.
*/
if (options === undefined || options.updated_view !== this) {
this.upload_container.disabled = this.model.get('disabled');
this.upload_container.setAttribute('accept', this.model.get('accept'));
if (this.model.get('multiple')) {
this.upload_container.setAttribute('multiple', 'true');
} else {
this.upload_container.removeAttribute('multiple');
}
this.loader.style.visibility = this.model.get('loading') ? 'visible' : 'hidden';
}
return super.update(options);
}

handleUploadChanged(event) {
const that = this;
const {files} = event.target;
// Clear data
that.model.set('_values_base64', []);
if (!files || files.length === 0) {
that.model.set('_metadata', []);
that.touch();
return;
}

this.model.set('loading', true);
const fileContentsPromises = [];
const metadataList = [];
const updateMetadata = () => {
that.model.set('_metadata', _.map(metadataList, _.clone));
that.touch();
};
for (let file of files) { // files it NOT an array
fileContentsPromises.push(new Promise((resolve, reject) => {
const metadata = {
name: file.name,
type: file.type,
lastModified: file.lastModified,
error: undefined,
};
metadataList.push(metadata);
const fileReader = new FileReader();
fileReader.onload = () => {
resolve(fileReader.result);
};
fileReader.onerror = () => {
metadata.error = fileReader.error.message;
updateMetadata();
resolve(); // Done, but no data
};
fileReader.onabort = fileReader.onerror;
// Using onprogress has issues as we do not know when that data is synced to jupyter
fileReader.readAsDataURL(file);
}));
}
updateMetadata();
Promise.all(fileContentsPromises)
.then((contents) => {
that.model.set('_values_base64', contents);
that.touch();
})
.catch((err) => {
// We do not use reject above, so no need to come up with a UI yet
console.error('FileUploadView Error loading files: %o', err);
});
}

upload_container: HTMLInputElement;
loader: HTMLDivElement;
model: UploadModel;
}
Loading