From 0add6e53959fd0e7395f35289d958827b8d5a611 Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Tue, 5 May 2020 13:24:38 -0700 Subject: Handle doubles and lists on the backend --- bh20simplewebuploader/main.py | 117 ++++++++++++++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 28 deletions(-) (limited to 'bh20simplewebuploader') diff --git a/bh20simplewebuploader/main.py b/bh20simplewebuploader/main.py index 126b8dd..7b6e6e1 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,12 @@ 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) else: raise NotImplementedError('Unimplemented input type: {}'.format(html_type)) @@ -299,31 +307,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 +587,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) -- cgit v1.2.3 From 07ff2d0f44d07bcca830f020e72ae2389a909f4f Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Tue, 5 May 2020 14:27:58 -0700 Subject: Add JS at front end for lists, and date support on backend --- bh20simplewebuploader/main.py | 3 ++ bh20simplewebuploader/static/main.css | 15 +++++-- bh20simplewebuploader/static/main.js | 75 +++++++++++++++++++++++++++++++ bh20simplewebuploader/templates/form.html | 56 +++++++++++++---------- 4 files changed, 121 insertions(+), 28 deletions(-) (limited to 'bh20simplewebuploader') diff --git a/bh20simplewebuploader/main.py b/bh20simplewebuploader/main.py index 7b6e6e1..1a441f0 100644 --- a/bh20simplewebuploader/main.py +++ b/bh20simplewebuploader/main.py @@ -268,6 +268,9 @@ def parse_input(input_string, html_type, number_step=None): 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)) diff --git a/bh20simplewebuploader/static/main.css b/bh20simplewebuploader/static/main.css index 57e29ef..80ee6b7 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 { @@ -184,6 +187,10 @@ pre code { margin-top: 10px; } +.hidden { + display: none; +} + .search-section { display: flex; justify-content: space-between; diff --git a/bh20simplewebuploader/static/main.js b/bh20simplewebuploader/static/main.js index 96199a0..a67d3df 100644 --- a/bh20simplewebuploader/static/main.js +++ b/bh20simplewebuploader/static/main.js @@ -45,3 +45,78 @@ function displayForm() { } fillFormSpot.classList.add("invisible"); } + +/** + * 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('hidden') +} + +/** + * Remove the last form field from the group button is part of. + */ +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) + } + + 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('hidden') + } +} + +// 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..cea444c 100644 --- a/bh20simplewebuploader/templates/form.html +++ b/bh20simplewebuploader/templates/form.html @@ -32,10 +32,10 @@ @@ -116,26 +116,34 @@

{{ record['heading'] }}

{% else %} -
@@ -190,7 +198,7 @@ -- cgit v1.2.3 From 90bd12eba8aa938c4b2b40f24f716494fdc2f958 Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Tue, 5 May 2020 14:40:07 -0700 Subject: Use invisible class instead of hidden class --- bh20simplewebuploader/static/main.css | 4 ---- bh20simplewebuploader/static/main.js | 4 ++-- bh20simplewebuploader/templates/form.html | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 16 deletions(-) (limited to 'bh20simplewebuploader') diff --git a/bh20simplewebuploader/static/main.css b/bh20simplewebuploader/static/main.css index 80ee6b7..5c3d568 100644 --- a/bh20simplewebuploader/static/main.css +++ b/bh20simplewebuploader/static/main.css @@ -187,10 +187,6 @@ pre code { margin-top: 10px; } -.hidden { - display: none; -} - .search-section { display: flex; justify-content: space-between; diff --git a/bh20simplewebuploader/static/main.js b/bh20simplewebuploader/static/main.js index a67d3df..6a1daa6 100644 --- a/bh20simplewebuploader/static/main.js +++ b/bh20simplewebuploader/static/main.js @@ -86,7 +86,7 @@ function addField(e) { fieldGroup.insertBefore(newField, minusButton) // Enable the minus button - minusButton.classList.remove('hidden') + minusButton.classList.remove('invisible') } /** @@ -108,7 +108,7 @@ function removeField(e) { 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('hidden') + this.classList.add('invisible') } } diff --git a/bh20simplewebuploader/templates/form.html b/bh20simplewebuploader/templates/form.html index cea444c..ed4c9fb 100644 --- a/bh20simplewebuploader/templates/form.html +++ b/bh20simplewebuploader/templates/form.html @@ -95,13 +95,13 @@ Make sure the metadata has submitter attribution details.
-
-
- -
- -
-
+
+
+ +
+ +
+
@@ -140,7 +140,7 @@ {% endif %} {% if record['list'] %} - + {% endif %} @@ -153,8 +153,8 @@ - - + +
-- cgit v1.2.3 From fa04ea5388a46746bc219e9bd4adef1d973b9d19 Mon Sep 17 00:00:00 2001 From: Adam Novak Date: Tue, 5 May 2020 14:51:51 -0700 Subject: Add back necessary form-swapping javascript This partially reverts 03cbed808805ccdbff639eaa67e8f8b26c7275b3. Unless we actually take the inactive form fields out of the form in the DOM, their "required" attributes will prevent form submission. We can't just hide them. --- bh20simplewebuploader/static/main.css | 7 ---- bh20simplewebuploader/static/main.js | 57 ++++++++++++++++++++++++++----- bh20simplewebuploader/templates/form.html | 5 ++- 3 files changed, 50 insertions(+), 19 deletions(-) (limited to 'bh20simplewebuploader') diff --git a/bh20simplewebuploader/static/main.css b/bh20simplewebuploader/static/main.css index 5c3d568..c881253 100644 --- a/bh20simplewebuploader/static/main.css +++ b/bh20simplewebuploader/static/main.css @@ -235,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 6a1daa6..56213fa 100644 --- a/bh20simplewebuploader/static/main.js +++ b/bh20simplewebuploader/static/main.js @@ -34,18 +34,57 @@ let fetchAllaccessions = () => { fetchAPI("/api/getAllaccessions"); }; -/** - * Show form if checked +/* + * 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 fillFormSpot = document.getElementById('metadata_fill_form_spot'); -function displayForm() { - if (document.getElementById('metadata_form').checked) { - fillFormSpot.classList.remove("invisible"); - return; - } - fillFormSpot.classList.add("invisible"); + +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. */ diff --git a/bh20simplewebuploader/templates/form.html b/bh20simplewebuploader/templates/form.html index ed4c9fb..1bbf515 100644 --- a/bh20simplewebuploader/templates/form.html +++ b/bh20simplewebuploader/templates/form.html @@ -87,9 +87,9 @@

- + - +
Make sure the metadata has submitter attribution details. @@ -153,7 +153,6 @@
- -- cgit v1.2.3