about summary refs log tree commit diff
path: root/bh20simplewebuploader
diff options
context:
space:
mode:
Diffstat (limited to 'bh20simplewebuploader')
-rw-r--r--bh20simplewebuploader/main.py120
-rw-r--r--bh20simplewebuploader/static/main.css18
-rw-r--r--bh20simplewebuploader/static/main.js128
-rw-r--r--bh20simplewebuploader/templates/form.html77
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>