Commit dc4923e5 authored by BAIRE Anthony's avatar BAIRE Anthony
Browse files

Refactor the job input file selectors

This adds support for:
- uploading multiple files from separate directories
  (close allgo.inria.fr#7)
- removing a file
  (close #338)
- drag-and-drop

I used the bs-custom-file-input plugin because this is the one
used by the official bootstrap doc.
parent 2618c6be
......@@ -66,9 +66,9 @@ class JobForm(forms.ModelForm):
files = forms.FileField(
widget=forms.FileInput(attrs={'multiple': True}),
required=False,
label='Files to upload',
label='Input files',
label_suffix='',
help_text='Click "Choose file" to select and upload your job\'s input files')
help_text='Select or drag-and-drop the input files to be uploaded.')
queue_id = forms.ModelChoiceField(
queryset=JobQueue.objects.all().distinct().order_by("timeout"),
initial=1,
......
......@@ -1227,6 +1227,9 @@ class JobCreate(AllAccessMixin, SuccessMessageMixin, CreateView):
kwargs["job_result_cmd"] = ["curl", "-H", auth,
base_url + reverse("api:job", args=(42,)).replace("42", "<job_id>")]
# number of file selectors to be generated in the form
kwargs["file_input_ids"] = range(16)
return super().get_context_data(**kwargs)
def get_form_kwargs(self):
......
/*!
* bsCustomFileInput v1.3.4 (https://github.com/Johann-S/bs-custom-file-input)
* Copyright 2018 - 2020 Johann-S <johann.servoire@gmail.com>
* Licensed under MIT (https://github.com/Johann-S/bs-custom-file-input/blob/master/LICENSE)
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).bsCustomFileInput=t()}(this,function(){"use strict";var s={CUSTOMFILE:'.custom-file input[type="file"]',CUSTOMFILELABEL:".custom-file-label",FORM:"form",INPUT:"input"},l=function(e){if(0<e.childNodes.length)for(var t=[].slice.call(e.childNodes),n=0;n<t.length;n++){var l=t[n];if(3!==l.nodeType)return l}return e},u=function(e){var t=e.bsCustomFileInput.defaultText,n=e.parentNode.querySelector(s.CUSTOMFILELABEL);n&&(l(n).textContent=t)},n=!!window.File,r=function(e){if(e.hasAttribute("multiple")&&n)return[].slice.call(e.files).map(function(e){return e.name}).join(", ");if(-1===e.value.indexOf("fakepath"))return e.value;var t=e.value.split("\\");return t[t.length-1]};function d(){var e=this.parentNode.querySelector(s.CUSTOMFILELABEL);if(e){var t=l(e),n=r(this);n.length?t.textContent=n:u(this)}}function v(){for(var e=[].slice.call(this.querySelectorAll(s.INPUT)).filter(function(e){return!!e.bsCustomFileInput}),t=0,n=e.length;t<n;t++)u(e[t])}var p="bsCustomFileInput",m="reset",h="change";return{init:function(e,t){void 0===e&&(e=s.CUSTOMFILE),void 0===t&&(t=s.FORM);for(var n,l,r=[].slice.call(document.querySelectorAll(e)),i=[].slice.call(document.querySelectorAll(t)),o=0,u=r.length;o<u;o++){var c=r[o];Object.defineProperty(c,p,{value:{defaultText:(n=void 0,n="",(l=c.parentNode.querySelector(s.CUSTOMFILELABEL))&&(n=l.textContent),n)},writable:!0}),d.call(c),c.addEventListener(h,d)}for(var f=0,a=i.length;f<a;f++)i[f].addEventListener(m,v),Object.defineProperty(i[f],p,{value:!0,writable:!0})},destroy:function(){for(var e=[].slice.call(document.querySelectorAll(s.FORM)).filter(function(e){return!!e.bsCustomFileInput}),t=[].slice.call(document.querySelectorAll(s.INPUT)).filter(function(e){return!!e.bsCustomFileInput}),n=0,l=t.length;n<l;n++){var r=t[n];u(r),r[p]=void 0,r.removeEventListener(h,d)}for(var i=0,o=e.length;i<o;i++)e[i].removeEventListener(m,v),e[i][p]=void 0}}});
//# sourceMappingURL=bs-custom-file-input.min.js.map
{"version":3,"file":"bs-custom-file-input.min.js","sources":["../src/selector.js","../src/util.js","../src/eventHandlers.js","../src/index.js"],"sourcesContent":["const Selector = {\n CUSTOMFILE: '.custom-file input[type=\"file\"]',\n CUSTOMFILELABEL: '.custom-file-label',\n FORM: 'form',\n INPUT: 'input',\n}\n\nexport default Selector\n","import Selector from './selector'\n\nconst textNodeType = 3\nconst getDefaultText = (input) => {\n let defaultText = ''\n\n const label = input.parentNode.querySelector(Selector.CUSTOMFILELABEL)\n\n if (label) {\n defaultText = label.textContent\n }\n\n return defaultText\n}\n\nconst findFirstChildNode = (element) => {\n if (element.childNodes.length > 0) {\n const childNodes = [].slice.call(element.childNodes)\n\n for (let i = 0; i < childNodes.length; i++) {\n const node = childNodes[i]\n if (node.nodeType !== textNodeType) {\n return node\n }\n }\n }\n\n return element\n}\n\nconst restoreDefaultText = (input) => {\n const defaultText = input.bsCustomFileInput.defaultText\n const label = input.parentNode.querySelector(Selector.CUSTOMFILELABEL)\n\n if (label) {\n const element = findFirstChildNode(label)\n\n element.textContent = defaultText\n }\n}\n\nexport {\n getDefaultText,\n findFirstChildNode,\n restoreDefaultText,\n}\n","import { findFirstChildNode, restoreDefaultText } from './util'\nimport Selector from './selector'\n\nconst fileApi = !!window.File\nconst FAKE_PATH = 'fakepath'\nconst FAKE_PATH_SEPARATOR = '\\\\'\n\nconst getSelectedFiles = (input) => {\n if (input.hasAttribute('multiple') && fileApi) {\n return [].slice.call(input.files)\n .map((file) => file.name)\n .join(', ')\n }\n\n if (input.value.indexOf(FAKE_PATH) !== -1) {\n const splittedValue = input.value.split(FAKE_PATH_SEPARATOR)\n\n return splittedValue[splittedValue.length - 1]\n }\n\n return input.value\n}\n\nfunction handleInputChange() {\n const label = this.parentNode.querySelector(Selector.CUSTOMFILELABEL)\n\n if (label) {\n const element = findFirstChildNode(label)\n const inputValue = getSelectedFiles(this)\n\n if (inputValue.length) {\n element.textContent = inputValue\n } else {\n restoreDefaultText(this)\n }\n }\n}\n\nfunction handleFormReset() {\n const customFileList = [].slice.call(this.querySelectorAll(Selector.INPUT))\n .filter((input) => !!input.bsCustomFileInput)\n\n for (let i = 0, len = customFileList.length; i < len; i++) {\n restoreDefaultText(customFileList[i])\n }\n}\n\nexport {\n handleInputChange,\n handleFormReset,\n}\n","import { getDefaultText, restoreDefaultText } from './util'\nimport { handleFormReset, handleInputChange } from './eventHandlers'\nimport Selector from './selector'\n\nconst customProperty = 'bsCustomFileInput'\nconst Event = {\n FORMRESET : 'reset',\n INPUTCHANGE : 'change',\n}\n\nconst bsCustomFileInput = {\n init(inputSelector = Selector.CUSTOMFILE, formSelector = Selector.FORM) {\n const customFileInputList = [].slice.call(document.querySelectorAll(inputSelector))\n const formList = [].slice.call(document.querySelectorAll(formSelector))\n\n for (let i = 0, len = customFileInputList.length; i < len; i++) {\n const input = customFileInputList[i]\n\n Object.defineProperty(input, customProperty, {\n value: {\n defaultText: getDefaultText(input),\n },\n writable: true,\n })\n\n handleInputChange.call(input)\n input.addEventListener(Event.INPUTCHANGE, handleInputChange)\n }\n\n for (let i = 0, len = formList.length; i < len; i++) {\n formList[i].addEventListener(Event.FORMRESET, handleFormReset)\n Object.defineProperty(formList[i], customProperty, {\n value: true,\n writable: true,\n })\n }\n },\n\n destroy() {\n const formList = [].slice.call(document.querySelectorAll(Selector.FORM))\n .filter((form) => !!form.bsCustomFileInput)\n const customFileInputList = [].slice.call(document.querySelectorAll(Selector.INPUT))\n .filter((input) => !!input.bsCustomFileInput)\n\n for (let i = 0, len = customFileInputList.length; i < len; i++) {\n const input = customFileInputList[i]\n\n restoreDefaultText(input)\n input[customProperty] = undefined\n\n input.removeEventListener(Event.INPUTCHANGE, handleInputChange)\n }\n\n for (let i = 0, len = formList.length; i < len; i++) {\n formList[i].removeEventListener(Event.FORMRESET, handleFormReset)\n formList[i][customProperty] = undefined\n }\n },\n}\n\nexport default bsCustomFileInput\n"],"names":["Selector","CUSTOMFILE","CUSTOMFILELABEL","FORM","INPUT","findFirstChildNode","element","childNodes","length","slice","call","i","node","nodeType","restoreDefaultText","input","defaultText","bsCustomFileInput","label","parentNode","querySelector","textContent","fileApi","window","File","getSelectedFiles","hasAttribute","files","map","file","name","join","value","indexOf","splittedValue","split","handleInputChange","this","inputValue","handleFormReset","customFileList","querySelectorAll","filter","len","customProperty","Event","init","inputSelector","formSelector","customFileInputList","document","formList","Object","defineProperty","writable","addEventListener","destroy","form","undefined","removeEventListener"],"mappings":"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"}
\ No newline at end of file
......@@ -104,21 +104,24 @@
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-group">
<div class="form-group" id="files-form-group">
{{ form.files.label_tag }}
<button type="button" class="btn btn-secondary btn-sm" onClick="add_input_file($(this).next())">+</button>
<span class="form-inline input-group">
<label for="input-file01" class="btn btn-secondary mr-sm-2" tabindex="0">Choose file</label>
<input type="file" id="input-file01" name="{{ form.files.name }}" style="display:none"
multiple onChange="list_filenames_in_next_elt(this)">
<input type="text" class="mr-sm-2 form-control" disabled >
<button type="button" class="btn btn-secondary" onClick="$(this).prev()[0].value='';">x</button>
</span>
{% for i in file_input_ids %}
<div class="input-group"{% if not forloop.first %} hidden{% endif %}>
<div class="input-group-prepend">
<button type="button" class="btn btn-outline-secondary" data-action="remove">×</button>
</div>
<div class="custom-file">
<input id="input-file-{{i}}" class="custom-file-input" type="file" name="{{ form.files.name }}" multiple>
<label for="input-file-{{i}}" class="custom-file-label"></label>
</div>
</div>
{% endfor %}
<small class="form-text text-muted">{{ form.files.help_text }}</small>
</div>
{{ form.param | label_with_classes:"d-block" }}
<div class="form-group">
{{ form.param | label_with_classes:"d-block" }}
<div class="input-group">
<div class="input-group-prepend">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Presets</button>
......@@ -236,51 +239,58 @@
{{ block.super }}
<script defer src="{% static 'js/tooltip.js' %}"></script>
<script defer src="{% static 'js/copy.js' %}"></script>
<script>
function list_filenames_in_next_elt(elt)
{
let files = $.map( elt.files, function(v){ return v.name; } );
$(elt).next()[0].value = files.join(",");
}
{
/* == File chooser - to allow selection of file from different directories ==
* closure to reduce to scope of variables.
* i_btn : counter used to define ids.
*
* The file-chooser is composed of those elements :
* span
* - label, used as button to activate the input-file
* - input type file, hidden.
* - input type text, used to display the selected files.
* This seems to be quite "classical" management of input-file when style is involved.
* https://www.quirksmode.org/dom/inputfile.html
* https://buzut.net/customisez-le-input-file/
* https://www.creativejuiz.fr/blog/tutoriels/input-file-personnalise-css-js
*
* Here we clone the first span element, update ids
* and insert the new span right before the small help text line.
*/
let i_btn = 2;
function add_input_file(span_elt)
{
let new_span = $(span_elt).clone(false);
let new_id = "input-file0"+i_btn;
new_span.children('label')
.prop("for", new_id);
inputs = new_span.children('input');
inputs[0].id = new_id;
inputs[1].value = '';
++i_btn;
let last_small = $(span_elt).parent().children('small').last();
last_small.before(new_span);
<script defer src="{% static 'js/bs-custom-file-input.min.js' %}"></script>
<script defer>
$(document).ready(function(){
{% comment %}
Support for multiple file selectors
HTML allows uploading multiple files via a single file selector:
<input type="file" multiple>
However most browser won't allow uploading files from separate
directories, which is bad for user experience. We need to support
multiple selectors.
Creating additional selectors dynamically is a possible approach, but
it seems to break firefox (as of esr 68). The requests sent by the
browser are empty and django reports a CSRF token failure.
Therefore our approach is to generate the form with a fixed set of file
selectors and keep them hidden until they are needed.
{% endcomment %}
var groups = $("#files-form-group").find(".input-group");
{# ensure that: #}
{# - all non-empty file selectors are visible #}
{# - exactly one empty file selector is visible #}
function refresh_visibility() {
var has_empty_slot = false;
groups.each(function(_, grp) {
if ($(grp).find("input").val() == "") {
if (has_empty_slot) {
grp.setAttribute("hidden", "");
return;
}
has_empty_slot = true;
}
grp.removeAttribute("hidden");
});
}
function on_remove(ev) {
grp = $(ev.target).parentsUntil(".input-group").last().parent();
grp.find("input").val("");
grp.find("label").text("");
refresh_visibility();
}
} // == File chooser
</script>
bsCustomFileInput.init();
groups.find("button[data-action='remove']").click(on_remove);
groups.find("input").change(refresh_visibility);
refresh_visibility();
});
</script>
{% endblock %}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment