diff options
Diffstat (limited to 'bh20simplewebuploader')
-rw-r--r-- | bh20simplewebuploader/main.py | 120 | ||||
-rw-r--r-- | bh20simplewebuploader/static/main.css | 18 | ||||
-rw-r--r-- | bh20simplewebuploader/static/main.js | 128 | ||||
-rw-r--r-- | bh20simplewebuploader/templates/form.html | 77 |
4 files changed, 262 insertions, 81 deletions
diff --git a/bh20simplewebuploader/main.py b/bh20simplewebuploader/main.py index 126b8dd..1a441f0 100644 --- a/bh20simplewebuploader/main.py +++ b/bh20simplewebuploader/main.py @@ -1,4 +1,5 @@ import collections +import itertools import tempfile import shutil import subprocess @@ -153,11 +154,19 @@ def generate_form(schema, options): # Decide if the field is optional (type ends in ?) optional = False - if len(field_type) > 0 and field_type[-1] == '?': + if field_type.endswith('?'): # It's optional optional = True # Drop the ? field_type = field_type[:-1] + + # Decide if the field is a list (type ends in []) + is_list = False + if field_type.endswith('[]'): + # It's a list + is_list = True + # Reduce to the normal type + field_type = field_type[:-2] if field_type in by_name: # This is a subrecord. We need to recurse @@ -169,6 +178,7 @@ def generate_form(schema, options): record['id'] = '.'.join(parent_keys + [field_name]) record['label'] = name_to_label(field_name) record['required'] = not optional and not subtree_optional + record['list'] = is_list if ref_iri: record['ref_iri'] = ref_iri if docstring: @@ -193,18 +203,10 @@ def generate_form(schema, options): record['type'] = 'text' elif field_type == 'int': record['type'] = 'number' - elif field_type == 'float': + elif field_type == 'float' or field_type == 'double': record['type'] = 'number' # Choose a reasonable precision for the control record['step'] = '0.0001' - - ### This is to fix the homepage for the moment ## needs more love though - # implementation of the [] stuff instead of just text fields - ## ToDo - implement lists - elif field_type == 'string[]': - record['type'] = 'text' - elif field_type == 'float[]': - record['type'] = 'text' else: raise NotImplementedError('Unimplemented field type {} in {} in metadata schema'.format(field_type, type_name)) yield record @@ -248,9 +250,10 @@ def copy_with_limit(in_file, out_file, limit=1024*1024): buf = in_file.read(buf_size) bytes_used += len(buf) -def parse_input(input_string, html_type): +def parse_input(input_string, html_type, number_step=None): """ Parse an input from the given HTML input type into a useful Python type. + Also needs the step we sent to distinguish int fields and float/double fields. Raise ValueError if something does not parse. Raise NotImplementedError if we forgot to implement a type. @@ -259,7 +262,15 @@ def parse_input(input_string, html_type): if html_type == 'text': return input_string elif html_type == 'number': - return int(input_string) + # May be an int or a float. + if number_step is None: + # TODO: Assumes we only use the step for floats + return int(input_string) + else: + return float(input_string) + elif html_type == 'date': + # Don't do our own date validation; pass it on as a string + return input_string else: raise NotImplementedError('Unimplemented input type: {}'.format(html_type)) @@ -299,31 +310,84 @@ def receive_files(): elif request.form.get('metadata_type', None) == 'fill': # Build a metadata dict metadata = {} + + # When we have metadata for an item, use this to set it. + # If it is an item in a list, set is_list=True + def set_metadata(item_id, value, is_list=False): + # We have this thing. Make a place in the dict tree for it. + parts = item_id.split('.') + key = parts[-1] + # Remove leading 'metadata' + path = parts[1:-1] + dest_dict = metadata + for parent in path: + if parent not in dest_dict: + dest_dict[parent] = {} + dest_dict = dest_dict[parent] + + if not is_list: + dest_dict[key] = value + else: + if key not in dest_dict: + dest_dict[key] = [] + dest_dict[key].append(value) for item in FORM_ITEMS: # Pull all the field values we wanted from the form if 'heading' in item: continue - - if item['id'] in request.form and len(request.form[item['id']]) > 0: - # We have this thing. Make a place in the dict tree for it. - parts = item['id'].split('.') - key = parts[-1] - # Remove leading 'metadata' - path = parts[1:-1] - dest_dict = metadata - for parent in path: - if parent not in dest_dict: - dest_dict[parent] = {} - dest_dict = dest_dict[parent] - + + if item['list']: + # This is a list, serialized into form fields + + # We count how many values we got + value_count = 0 + + for index in itertools.count(): + # Get [0] through [n], until something isn't there. + entry_id = '{}[{}]'.format(item['id'], index) + + if index == 1000: + # Don't let them provide too much stuff. + return (render_template('error.html', + error_message="You provided an extremely large number of values for the metadata item {}".format(item['id'])), 403) + + if entry_id in request.form: + if len(request.form[entry_id]) > 0: + # Put an entry in the list + try: + # Parse the item + parsed = parse_input(request.form[entry_id], item['type'], item.get('step', None)) + except ValueError: + # We don't like that input + return (render_template('error.html', + error_message="You provided an unacceptable value for the metadata item {}".format(entry_id)), 403) + # Save it + set_metadata(item['id'], parsed, is_list=True) + value_count += 1 + else: + # Empty items are silently skipped. + pass + else: + # We have run out of form fields for this list. + break + + if item['required'] and value_count == 0: + # They forgot a required item. Maybe all entries were empty. + return (render_template('error.html', + error_message="You omitted any values for the required metadata item {}".format(item['id'])), 403) + + elif item['id'] in request.form and len(request.form[item['id']]) > 0: + # Not a list, but a single item which is present. try: - # Now finally add the item - dest_dict[key] = parse_input(request.form[item['id']], item['type']) + # Parse the item + parsed = parse_input(request.form[item['id']], item['type'], item.get('step', None)) except ValueError: # We don't like that input return (render_template('error.html', error_message="You provided an unacceptable value for the metadata item {}".format(item['id'])), 403) + # Save it + set_metadata(item['id'], parsed) elif item['required']: return (render_template('error.html', error_message="You omitted the required metadata item {}".format(item['id'])), 403) @@ -526,4 +590,4 @@ def getSEQbyLocationAndSpecimenSource(): payload = {'query': query, 'format': 'json'} r = requests.get(baseURL, params=payload) result = r.json()['results']['bindings'] - return str(result)
\ No newline at end of file + return str(result) diff --git a/bh20simplewebuploader/static/main.css b/bh20simplewebuploader/static/main.css index 57e29ef..c881253 100644 --- a/bh20simplewebuploader/static/main.css +++ b/bh20simplewebuploader/static/main.css @@ -167,16 +167,19 @@ pre code { border: solid 1px black; } -.record { +.record, .record .field-group, .record .field-group .field { display: flex; flex-direction: column; + -webkit-column-break-inside: avoid; /* Chrome, Safari, Opera */ + page-break-inside: avoid; /* Firefox */ + break-inside: avoid; +} + +.record { border: solid 1px #808080; padding: 1em; background: #F8F8F8; margin-bottom: 1em; - -webkit-column-break-inside: avoid; /* Chrome, Safari, Opera */ - page-break-inside: avoid; /* Firefox */ - break-inside: avoid; } .record label { @@ -232,13 +235,6 @@ footer { .sponsors img { width: 100%; } -.metadata input#metadata_upload:checked ~ #metadata_upload_form_spot { - display: block; -} - -.metadata input#metadata_upload ~ #metadata_upload_form_spot { - display: none; -} .loader { display: block; diff --git a/bh20simplewebuploader/static/main.js b/bh20simplewebuploader/static/main.js index 96199a0..56213fa 100644 --- a/bh20simplewebuploader/static/main.js +++ b/bh20simplewebuploader/static/main.js @@ -34,14 +34,128 @@ let fetchAllaccessions = () => { fetchAPI("/api/getAllaccessions"); }; +/* + * Make sure that only one of the manual metadata entry and metadata upload + * form components is *actually* a child of the form element in the DOM. + * + * Because both make use of the "required" attribute, we can't get away with + * just hiding the one we don't want the user to fill in. The hidden part will + * still have possibly empty required fields and (some) browsers will + * blocksubmission because of it. Moreover, the data (including file uploads) + * from the hidden elements will still be sent to the server, which the user + * may not expect. + */ + +let uploadForm = document.getElementById('metadata_upload_form') +let uploadFormSpot = document.getElementById('metadata_upload_form_spot') +let fillForm = document.getElementById('metadata_fill_form') +let fillFormSpot = document.getElementById('metadata_fill_form_spot') + +function setUploadMode() { + // Make the upload form the one in use. + uploadFormSpot.appendChild(uploadForm) + // Remove the upload form from the DOM so its required-ness does not block submission. + fillFormSpot.removeChild(fillForm) +} + +function setFillMode() { + // Make the fillable form the one in use + uploadFormSpot.removeChild(uploadForm) + // Remove the fillable form from the DOM so its required-ness does not block submission. + fillFormSpot.appendChild(fillForm) +} + +function setMode() { + // Pick mode based on radio + if (document.getElementById('metadata_upload').checked) { + setUploadMode() + } else { + setFillMode() + } +} + +/* + * Machinery for variable-length lists of input items. + */ + +// Start in mode appropriate to selected form item. +// It is important that we run this code when the page starts! The browser may +// have set the radio button to whatever the state was on last page load, +// instead of the default state, without raising an event, and we have to +// handle that. +setMode() + +/** + * Add another form field to the group this button is part of. + */ +function addField(e) { + // Find our parent field-group div + let fieldGroup = this.parentElement + + // Get its keypath + let keypath = fieldGroup.dataset.keypath + + // Find its last field child + let existingFields = fieldGroup.getElementsByClassName('field') + let templateField = existingFields[existingFields.length - 1] + + // Get its number + let fieldNumber = templateField.dataset.number + + // Duplicate it + let newField = templateField.cloneNode(true) + + // Increment the number and use the keypath and number to set IDs and cross + // references. + // TODO: Heavily dependent on the form field HTML. Maybe we want custom + // elements for the labeled controlsd that know how to be list items? + fieldNumber++ + newField.dataset.number = fieldNumber + let newID = keypath + '[' + fieldNumber + ']' + let newControl = newField.getElementsByClassName('control')[0] + newControl.id = newID + newControl.setAttribute('name', newID) + let newLabel = newField.getElementsByTagName('label')[0] + newLabel.setAttribute('for', newID) + + // Find the minus button + let minusButton = fieldGroup.getElementsByClassName('remove-field')[0] + + // Put new field as a child before the minus button + fieldGroup.insertBefore(newField, minusButton) + + // Enable the minus button + minusButton.classList.remove('invisible') +} + /** - * Show form if checked + * Remove the last form field from the group button is part of. */ -let fillFormSpot = document.getElementById('metadata_fill_form_spot'); -function displayForm() { - if (document.getElementById('metadata_form').checked) { - fillFormSpot.classList.remove("invisible"); - return; +function removeField(e) { + // Find our parent field-group div + let fieldGroup = this.parentElement + + // Find its field children + let existingFields = fieldGroup.getElementsByClassName('field') + + if (existingFields.length > 1) { + // There is a last field we can safely remove. + let lastField = existingFields[existingFields.length - 1] + fieldGroup.removeChild(lastField) } - fillFormSpot.classList.add("invisible"); + + if (existingFields.length <= 1) { + // Collection auto-updates. Now there's only one element. Don't let the + // user remove it. If they don't want it, they can leave it empty. + this.classList.add('invisible') + } +} + +// Find all the add and remove field buttons and hook up the listeners. +for (let button of document.getElementsByClassName('add-field')) { + button.addEventListener('click', addField) } +for (let button of document.getElementsByClassName('remove-field')) { + button.addEventListener('click', removeField) +} + diff --git a/bh20simplewebuploader/templates/form.html b/bh20simplewebuploader/templates/form.html index ffd4158..1bbf515 100644 --- a/bh20simplewebuploader/templates/form.html +++ b/bh20simplewebuploader/templates/form.html @@ -32,10 +32,10 @@ <div class="search"> <input id="search-input" id="global-search" type="search" placeholder="FASTA uri" required> <button class="button search-button" type="submit" onclick="search()"> - <span class="icon ion-search"> - <span class="sr-only">Search</span> - </span> - </button> + <span class="icon ion-search"> + <span class="sr-only">Search</span> + </span> + </button> </div> </section> @@ -87,21 +87,21 @@ <div class="metadata"> <label>Select metadata submission method:</label> <br> - <input type="radio" id="metadata_form" name="metadata_type" value="fill" checked onchange="displayForm()" required> + <input type="radio" id="metadata_form" name="metadata_type" value="fill" checked onchange="setMode()" required> <label for="metadata_form">Fill in metadata manually</label> - <input type="radio" id="metadata_upload" name="metadata_type" value="upload" onchange="displayForm()" required> + <input type="radio" id="metadata_upload" name="metadata_type" value="upload" onchange="setMode()" required> <label for="metadata_upload">Upload metadata file</label> <br> <small>Make sure the metadata has submitter attribution details.</small> <div id="metadata_upload_form_spot"> - <div id="metadata_upload_form"> - <br> - <label for="metadata">Select JSON or YAML metadata file following <a href="https://github.com/arvados/bh20-seq-resource/blob/master/bh20sequploader/bh20seq-schema.yml" target="_blank">this schema</a> and <a href="https://github.com/arvados/bh20-seq-resource/blob/master/example/metadata.yaml" target="_blank">example</a> (max 50K):</label> - <br> - <input type="file" id="metadata" name="metadata" accept=".json,.yml,.yaml" required> - <br> - </div> + <div id="metadata_upload_form"> + <br> + <label for="metadata">Select JSON or YAML metadata file following <a href="https://github.com/arvados/bh20-seq-resource/blob/master/bh20sequploader/bh20seq-schema.yml" target="_blank">this schema</a> and <a href="https://github.com/arvados/bh20-seq-resource/blob/master/example/metadata.yaml" target="_blank">example</a> (max 50K):</label> + <br> + <input type="file" id="metadata" name="metadata" accept=".json,.yml,.yaml" required> + <br> + </div> </div> </div> @@ -116,26 +116,34 @@ <div class="record"> <h4>{{ record['heading'] }}</h4> {% else %} - <label for="{{ record['id'] }}" title="{{ record.get('docstring', '') }}"> - {{ record['label'] }} - {{ "*" if record['required'] else "" }} - {% if 'docstring' in record %} - <a href='javascript:alert({{ record['docstring'] | tojson }})'>❓</a> - {% endif %} - {% if 'ref_iri' in record %} - <a href="{{ record['ref_iri'] }}" target="_blank" title="Ontology Link">🔗</a> + <div class="field-group" data-keypath="{{ record['id'] }}"> + <div class="field" data-number="0"> + <label for="{{ record['id'] }}{{ '[0]' if record['list'] else ''}}" title="{{ record.get('docstring', '') }}"> + {{ record['label'] }} + {{ "*" if record['required'] else "" }} + {% if 'docstring' in record %} + <a href='javascript:alert({{ record['docstring'] | tojson }})'>❓</a> + {% endif %} + {% if 'ref_iri' in record %} + <a href="{{ record['ref_iri'] }}" target="_blank" title="Ontology Link">🔗</a> + {% endif %} + </label> + {% if record['type'] == 'select' %} + <select class="control" id="{{ record['id'] }}{{ '[0]' if record['list'] else ''}}" name="{{ record['id'] }}{{ '[0]' if record['list'] else ''}}" {{ "required" if record['required'] else "" }}> + <option value="" selected>Choose one...</option> + {% for option in record['options'] %} + <option value="{{ option[1] }}">{{ option[0] }}</option> + {% endfor %} + </select> + {% else %} + <input class="control" type="{{ record['type'] }}" id="{{ record['id'] }}{{ '[0]' if record['list'] else ''}}" name="{{ record['id'] }}{{ '[0]' if record['list'] else ''}}" {{ "required" if record['required'] else "" }} {{ ("step=" + record['step']) if 'step' in record else ""}}> + {% endif %} + </div> + {% if record['list'] %} + <button type="button" title="Remove field" class="remove-field invisible">➖</button> + <button type="button" title="Add field" class="add-field">➕</button> {% endif %} - </label> - {% if record['type'] == 'select' %} - <select id="{{ record['id'] }}" name="{{ record['id'] }}" {{ "required" if record['required'] else "" }}> - <option value="" selected>Choose one...</option> - {% for option in record['options'] %} - <option value="{{ option[1] }}">{{ option[0] }}</option> - {% endfor %} - </select> - {% else %} - <input type="{{ record['type'] }}" id="{{ record['id'] }}" name="{{ record['id'] }}" {{ "required" if record['required'] else "" }} {{ ("step=" + record['step']) if 'step' in record else ""}}> - {% endif %} + </div> {% endif %} {% if loop.index == loop.length %} </div> @@ -145,8 +153,7 @@ </div> - -<input class="submit" type="submit" value="Add to Pangenome"> + <input class="submit" type="submit" value="Add to Pangenome"> </form> </section> <br> @@ -190,7 +197,7 @@ </div> <script type="text/javascript"> - let scriptRoot = {{ request.script_root|tojson|safe }}; + let scriptRoot = {{ request.script_root|tojson|safe }}; </script> <script type="text/javascript" src="/static/main.js"></script> |