diff --git a/app/assets/javascripts/protocols/external_protocols_tab.js b/app/assets/javascripts/protocols/external_protocols_tab.js index eebabc86c..5f42bd52c 100644 --- a/app/assets/javascripts/protocols/external_protocols_tab.js +++ b/app/assets/javascripts/protocols/external_protocols_tab.js @@ -1,25 +1,214 @@ function applyClickCallbackOnProtocolCards() { $('.protocol-card').off('click').on('click', function(e) { + var currProtocolCard = $(this); + + // Check whether this card is already active and deactivate it + if ($(currProtocolCard).hasClass('active')) { + resetPreviewPanel(); + $(currProtocolCard).removeClass('active'); + } else { + $('.protocol-card').removeClass('active'); + currProtocolCard.addClass('active'); + + $.ajax({ + url: $(currProtocolCard).data('show-url'), + type: 'GET', + dataType: 'json', + data: { + protocol_source: $(currProtocolCard).data('protocol-source'), + protocol_id: $(currProtocolCard).data('show-protocol-id') + }, + beforeSend: animateSpinner($('.protocol-preview-panel'), true), + success: function(data) { + $('.empty-preview-panel').hide(); + $('.full-preview-panel').show(); + $('.btn-holder').html($(currProtocolCard).find('.external-import-btn').clone()); + $('.preview-iframe').contents().find('body').html(data.html); + + initLoadProtocolModalPreview(); + animateSpinner($('.protocol-preview-panel'), false); + }, + error: function(_error) { + // TODO: we should probably show some alert bubble + resetPreviewPanel(); + animateSpinner($('.protocol-preview-panel'), false); + } + }); + } + e.preventDefault(); + return false; + }); +} + +// Resets preview to the default state +function resetPreviewPanel() { + $('.empty-preview-panel').show(); + $('.full-preview-panel').hide(); +} + +// Reset whole view to the default state +function setDefaultViewState() { + resetPreviewPanel(); + $('.empty-text').show(); + $('.list-wrapper').hide(); +} + +// Apply AJAX callbacks onto the search box +function applySearchCallback() { + var timeout; + + // Submit form on every input in the search box + $('input[name="key"]').off('input').on('input', function() { + if (timeout) { + clearTimeout(timeout); + } + + timeout = setTimeout(function() { + $('form.protocols-search-bar').submit(); + }, 500); + }); + + // Submit form when clicking on sort buttons + $('input[name="sort_by"]').off('change').on('change', function() { + $('form.protocols-search-bar').submit(); + }); + + // Bind ajax calls on the form + $('form.protocols-search-bar').off('ajax:success').off('ajax:error') + .bind('ajax:success', function(evt, data, status, xhr) { + if (data.html) { + resetPreviewPanel(); + $('.empty-text').hide(); + $('.list-wrapper').show(); + + $('.list-wrapper').html(data.html); + applyClickCallbackOnProtocolCards(); + initLoadProtocolModalPreview(); + } else { + setDefaultViewState(); + } + }) + .bind("ajax:error", function(evt, xhr, status, error) { + setDefaultViewState(); + + console.log(xhr.responseText); + }); +} + +function resetFormErrors(modal) { + // Reset all errors + modal.find('form > .form-group.has-error').removeClass('has-error'); + modal.find('form > .form-group>span.help-block').html(''); + modal.find('.general-error > span').html(''); +} + +function showFormErrors(modal, errors) { + resetFormErrors(modal); + + // AR valdiation errors + Object.keys(errors.protocol).forEach(function(key) { + var input = modal.find('#protocol_' + key); + var msg; + msg = key.charAt(0).toUpperCase() + key.slice(1) + ': ' + errors.protocol[key].join(', '); + if ((input.length > 0) && (errors.protocol[key].length > 0)) { + input.parent().next('span.help-block').html(msg); + input.parent().parent().addClass('has-error'); + } else if (errors.protocol[key].length > 0) { + modal.find('.general-error > span').append(msg + '
'); + } + }); +} + +function renderTable(table) { + $(table).handsontable('render'); + // Yet another dirty hack to solve HandsOnTable problems + if (parseInt($(table).css('height'), 10) < parseInt($(table).css('max-height'), 10) - 30) { + $(table).find('.ht_master .wtHolder').css({ height: '100%', width: '100%' }); + } +} + +// Expand all steps +function expandAllSteps() { + $('.step .panel-collapse').collapse('show'); + $(document).find("[data-role='step-hot-table']").each(function() { + renderTable($(this)); + }); + $(document).find('span.collapse-step-icon').each(function() { + $(this).addClass('fa-caret-square-up'); + $(this).removeClass('fa-caret-square-down'); + }); +} + +function handleFormSubmit(modal) { + var form = modal.find('form'); + form.on('submit', function(e) { + var url = form.attr('action'); + e.preventDefault(); // avoid to execute the actual submit of the form. + e.stopPropagation(); + animateSpinner(modal, true); $.ajax({ - url: $(this).data('show-url'), - type: 'GET', - dataType: 'json', - data: { - protocol_source: $(this).data('protocol-source'), - protocol_id: $(this).data('show-protocol-id') - }, - beforeSend: animateSpinner($('.protocol-preview-panel'), true), + type: 'POST', + url: url, + data: form.serialize(), // serializes the form's elements. success: function(data) { - $('.empty-preview-panel').hide(); - $('.full-preview-panel').show(); - $('.preview-iframe').contents().find('body').html(data.html); - animateSpinner($('.protocol-preview-panel'), false); + animateSpinner(modal, false); + window.location.replace(data.redirect_url); }, - error: function(_error) { - // TODO: we should probably show some alert bubble - $('.empty-preview-panel').show(); - $('.full-preview-panel').hide(); - animateSpinner($('.protocol-preview-panel'), false); + error: function(data) { + showFormErrors(modal, data.responseJSON.errors); + }, + complete: function() { + animateSpinner(modal, false); + } + }); + }); +} + +function initLoadProtocolModalPreview() { + $('.external-import-btn').off('click').on('click', function(e) { + var link = $(this).parents('.protocol-card'); + + // When clicking on the banner button, we have no protocol-card parent + if (link.length === 0) { + link = $('.protocol-card.active'); + } + + animateSpinner(null, true); + $.ajax({ + url: link.data('url'), + type: 'GET', + data: { + protocol_source: link.data('protocol-source'), + protocol_client_id: link.data('id') + }, + dataType: 'json', + success: function(data) { + var modal = $('#protocol-preview-modal'); + var modalTitle = modal.find('.modal-title'); + var modalBody = modal.find('.modal-body'); + var modalFooter = modal.find('.modal-footer'); + + modalTitle.html(data.title); + modalBody.html(data.html); + modalFooter.html(data.footer); + initHandsOnTable(modalBody); + modal.modal('show'); + expandAllSteps(); + initHandsOnTable(modalBody); + + if (data.validation_errors) { + showFormErrors(modal, data.validation_errors); + } + + initFormSubmits(); + handleFormSubmit(modal); + }, + error: function(error) { + console.log(error.responseJSON.errors); + alert('Server error'); + }, + complete: function() { + animateSpinner(null, false); } }); e.preventDefault(); @@ -27,4 +216,14 @@ function applyClickCallbackOnProtocolCards() { }); } -applyClickCallbackOnProtocolCards(); +function initFormSubmits() { + var modal = $('#protocol-preview-modal'); + modal.find('button[data-action=import_protocol]').off('click').on('click', function() { + var form = modal.find('form'); + var hiddenField = form.find('#protocol_protocol_type'); + hiddenField.attr('value', $(this).data('import_type')); + form.submit(); + }); +} + +applySearchCallback(); diff --git a/app/assets/javascripts/sitewide/external_protocols.js b/app/assets/javascripts/sitewide/external_protocols.js deleted file mode 100644 index a93395187..000000000 --- a/app/assets/javascripts/sitewide/external_protocols.js +++ /dev/null @@ -1,142 +0,0 @@ -/* global animateSpinner initHandsOnTable */ -/* eslint-disable no-restricted-globals, no-alert */ - -var ExternalProtocols = (function() { - function resetFormErrors(modal) { - // Reset all errors - modal.find('form > .form-group.has-error').removeClass('has-error'); - modal.find('form > .form-group>span.help-block').html(''); - modal.find('.general-error > span').html(''); - } - - function showFormErrors(modal, errors) { - resetFormErrors(modal); - - // AR valdiation errors - Object.keys(errors.protocol).forEach(function(key) { - var input = modal.find('#protocol_' + key); - var msg; - msg = key.charAt(0).toUpperCase() + key.slice(1) + ': ' + errors.protocol[key].join(', '); - if ((input.length > 0) && (errors.protocol[key].length > 0)) { - input.parent().next('span.help-block').html(msg); - input.parent().parent().addClass('has-error'); - } else if (errors.protocol[key].length > 0) { - modal.find('.general-error > span').append(msg + '
'); - } - }); - } - - function renderTable(table) { - $(table).handsontable('render'); - // Yet another dirty hack to solve HandsOnTable problems - if (parseInt($(table).css('height'), 10) < parseInt($(table).css('max-height'), 10) - 30) { - $(table).find('.ht_master .wtHolder').css({ height: '100%', width: '100%' }); - } - } - - // Expand all steps - function expandAllSteps() { - $('.step .panel-collapse').collapse('show'); - $(document).find("[data-role='step-hot-table']").each(function() { - renderTable($(this)); - }); - $(document).find('span.collapse-step-icon').each(function() { - $(this).addClass('fa-caret-square-up'); - $(this).removeClass('fa-caret-square-down'); - }); - } - - function handleFormSubmit(modal) { - var form = modal.find('form'); - form.on('submit', function(e) { - var url = form.attr('action'); - e.preventDefault(); // avoid to execute the actual submit of the form. - e.stopPropagation(); - animateSpinner(modal, true); - $.ajax({ - type: 'POST', - url: url, - data: form.serialize(), // serializes the form's elements. - success: function(data) { - animateSpinner(modal, false); - window.location.replace(data.redirect_url); - }, - error: function(data) { - showFormErrors(modal, data.responseJSON.errors); - }, - complete: function() { - animateSpinner(modal, false); - } - }); - }); - } - - function initLoadProtocolModalPreview() { - var externalProtocols = $('.external-protocol-result'); - externalProtocols.on('click', 'a[data-action="external-import"]', function(e) { - var link = $(this); - animateSpinner(null, true); - $.ajax({ - url: link.attr('data-url'), - type: 'GET', - data: { - protocol_source: link.attr('data-source'), - protocol_client_id: link.attr('data-id') - }, - dataType: 'json', - success: function(data) { - var modal = $('#protocol-preview-modal'); - var modalTitle = modal.find('.modal-title'); - var modalBody = modal.find('.modal-body'); - var modalFooter = modal.find('.modal-footer'); - - modalTitle.html(data.title); - modalBody.html(data.html); - modalFooter.html(data.footer); - initHandsOnTable(modalBody); - modal.modal('show'); - expandAllSteps(); - initHandsOnTable(modalBody); - - if (data.validation_errors) { - showFormErrors(modal, data.validation_errors); - } - - handleFormSubmit(modal); - }, - error: function(error) { - console.log(error.responseJSON.errors); - alert('Server error'); - }, - complete: function() { - animateSpinner(null, false); - } - }); - e.preventDefault(); - return false; - }); - } - - function initFormSubmits() { - var modal = $('#protocol-preview-modal'); - modal.on('click', 'button[data-action=import_protocol]', function() { - var form = modal.find('form'); - var hiddenField = form.find('#protocol_protocol_type'); - hiddenField.attr('value', $(this).data('import_type')); - form.submit(); - }); - } - - return { - init: () => { - if ($('.external-protocols-tab').length > 0) { - initLoadProtocolModalPreview(); - initFormSubmits(); - } - } - }; -}()); - -$(document).on('turbolinks:load', function() { - ExternalProtocols.init(); -}); diff --git a/app/assets/stylesheets/protocol_management.scss b/app/assets/stylesheets/protocol_management.scss index 0ea1094d3..fbe09bd44 100644 --- a/app/assets/stylesheets/protocol_management.scss +++ b/app/assets/stylesheets/protocol_management.scss @@ -74,6 +74,12 @@ margin-bottom: 15px; padding-right: 0; + .service-provider { + display: flex; + align-items: center; + width: 50%; + } + .protocolsio-logo { height: 30px; width: 30px; @@ -82,25 +88,20 @@ .protocolsio-title { color: $brand-primary; font-size: 14px; + margin-left: 3px; vertical-align: middle; } - .protocols-search-bar { - padding-right: 0; - width: 70%; + .input-group { + margin-bottom: 0; - .input-group { - margin-bottom: 0; - width: 100%; + .form-control { + border-right: 0; + } - .form-control { - border-right: 0; - } - - .input-group-addon { - background: $color-white; - color: $color-silver-chalice; - } + .input-group-addon { + background: $color-white; + color: $color-silver-chalice; } } } @@ -149,6 +150,40 @@ font-size: 13px; margin-top: 20px; } + + .list-wrapper { + height: 100%; + overflow: auto; + } + + .protocol-card { + border-bottom: 1px solid $color-gainsboro; + margin-right: 20px; + padding: 12px 11px 7px 11px; + + &.active { + border: 2px solid $brand-primary; + border-radius: 2px; + box-shadow: 0 1px 4px 0 $color-black; + } + + &:hover { + background-color: rgba(64,161,215,0.1); + } + } + + .protocol-title { + color: $brand-primary; + font-size: 16px; + } + + .info-line { + color: $color-dove-gray; + font-size: 13px; + padding-left: 0; + padding-right: 0; + } + } .protocol-preview-panel { @@ -188,10 +223,35 @@ } .full-preview-panel { + height: 100%; + } + + .preview-banner { + align-items: center; + background-color: $color-white; + border-bottom: 1px solid $color-alto; + color: $color-dove-gray; + display: flex; + font-size: 16px; + height: 40px; + padding-left: 21px; + + .txt-holder { + padding-left: 0; + } + + .btn-holder { + padding-right: 21px; + } + } + + + .preview-holder { display: flex; flex-direction: column; height: 100%; overflow: hidden; + padding-bottom: 40px; padding-left: 21px; padding-right: 21px; width: 100%; @@ -204,4 +264,13 @@ } } } + + .external-import-btn { + background-color: $brand-primary; + border: none; + color: $color-white; + font-size: 12px; + padding-bottom: 3px; + padding-top: 3px; + } } diff --git a/app/controllers/external_protocols_controller.rb b/app/controllers/external_protocols_controller.rb index 619d7f2f4..1384646b2 100644 --- a/app/controllers/external_protocols_controller.rb +++ b/app/controllers/external_protocols_controller.rb @@ -7,7 +7,8 @@ class ExternalProtocolsController < ApplicationController # GET list_external_protocols def index service_call = ProtocolImporters::SearchProtocolsService - .call(protocol_source: 'protocolsio/v3', query_params: index_params) + .call(protocol_source: index_params[:protocol_source], + query_params: index_params) if service_call.succeed? render json: { @@ -101,7 +102,7 @@ class ExternalProtocolsController < ApplicationController end def index_params - params.permit(:protocol_source, :key, :page_id, :page_size, :order_field, :order_dir) + params.permit(:protocol_source, :key, :page_id, :page_size, :sort_by) end def show_params diff --git a/app/services/protocol_importers/search_protocols_service.rb b/app/services/protocol_importers/search_protocols_service.rb index f3fd73034..fdd61e5c5 100644 --- a/app/services/protocol_importers/search_protocols_service.rb +++ b/app/services/protocol_importers/search_protocols_service.rb @@ -11,7 +11,7 @@ module ProtocolImporters def initialize(protocol_source:, query_params: {}) @protocol_source = protocol_source - @query_params = query_params + @query_params = query_params.except(:protocol_source) @errors = Hash.new { |h, k| h[k] = {} } end @@ -51,16 +51,6 @@ module ProtocolImporters @errors[:invalid_params][:page_id] = 'Page needs to be positive' end - # try if order_field is ok - if @query_params[:order_field] && CONSTANTS[:available_order_fields].exclude?(@query_params[:order_field]&.to_sym) - @errors[:invalid_params][:order_field] = 'Order field is not ok' - end - - # try if order dir is ok - if @query_params[:order_field] && CONSTANTS[:available_order_dirs].exclude?(@query_params[:order_dir]&.to_sym) - @errors[:invalid_params][:order_dir] = 'Order dir is not ok' - end - # try if endpints exists @errors[:invalid_params][:source_endpoint] = 'Wrong source endpoint' unless endpoint_name&.is_a?(String) diff --git a/app/utilities/protocol_importers/protocols_io/v3/api_client.rb b/app/utilities/protocol_importers/protocols_io/v3/api_client.rb index 441dbd200..c8986e3a0 100644 --- a/app/utilities/protocol_importers/protocols_io/v3/api_client.rb +++ b/app/utilities/protocol_importers/protocols_io/v3/api_client.rb @@ -44,8 +44,13 @@ module ProtocolImporters # Default is 1. def protocol_list(query_params = {}) response = with_handle_network_errors do + sort_mappings = CONSTANTS[:sort_mappings] query = CONSTANTS.dig(:endpoints, :protocols, :default_query_params) - .merge(query_params) + .merge(query_params.except(:sort_by)) + + if sort_mappings[query_params[:sort_by]&.to_sym] + query = query.merge(sort_mappings[query_params[:sort_by].to_sym]) + end self.class.get('/protocols', query: query) end diff --git a/app/utilities/protocol_importers/protocols_io/v3/protocol_normalizer.rb b/app/utilities/protocol_importers/protocols_io/v3/protocol_normalizer.rb index cac909a40..decb3a068 100644 --- a/app/utilities/protocol_importers/protocols_io/v3/protocol_normalizer.rb +++ b/app/utilities/protocol_importers/protocols_io/v3/protocol_normalizer.rb @@ -50,11 +50,17 @@ module ProtocolImporters original_order = protocol_hash[:steps].map { |m| [m[:previous_id], m[:id]] }.to_h current_position = 0 while next_step_id + current_position += 1 steps[next_step_id][:position] = current_position next_step_id = original_order[next_step_id] end + # Check if step name are valid + steps.each do |step| + step[1][:name] = "Step #{(step[1][:position] + 1)}" if step[1][:name].blank? + end + { protocol: normalized_data } rescue StandardError => e raise ProtocolImporters::ProtocolsIO::V3::NormalizerError.new(e.class.to_s.downcase.to_sym), e.message @@ -69,6 +75,7 @@ module ProtocolImporters { id: e[:id], title: e[:title], + source: Constants::PROTOCOLS_IO_V3_API[:source_id], created_on: e[:created_on], authors: e[:authors].map { |a| a[:name] }.join(', '), nr_of_steps: e[:stats][:number_of_steps], diff --git a/app/views/protocol_importers/_list_of_protocol_cards.html.erb b/app/views/protocol_importers/_list_of_protocol_cards.html.erb index 4164c5c72..45cdd4e94 100644 --- a/app/views/protocol_importers/_list_of_protocol_cards.html.erb +++ b/app/views/protocol_importers/_list_of_protocol_cards.html.erb @@ -1,5 +1,4 @@ -<% protocols.each do |protocol| %> +<% protocols[:protocols].each do |protocol| %> <%= render partial: 'protocol_importers/protocol_card', locals: { protocol: protocol } %> -
<% end %> diff --git a/app/views/protocol_importers/_protocol_card.html.erb b/app/views/protocol_importers/_protocol_card.html.erb index f7311f7da..f7a28ae88 100644 --- a/app/views/protocol_importers/_protocol_card.html.erb +++ b/app/views/protocol_importers/_protocol_card.html.erb @@ -1,3 +1,23 @@ -
-

<%= protocol[:name] %>

+
+

<%= protocol[:title] %>

+ +
+
+ <%= l(Time.at(protocol[:created_on]).to_datetime, format: :full_date) %> • <%= protocol[:authors] %> +
+ +
+
+ <%= t('protocol_importers.card.views_and_steps', nr_of_views: protocol[:nr_of_views], nr_of_steps: protocol[:nr_of_steps]) %> +
+
+ +
+
+
diff --git a/app/views/protocols/index/_external_protocols_tab.html.erb b/app/views/protocols/index/_external_protocols_tab.html.erb index 64d408383..515525f6b 100644 --- a/app/views/protocols/index/_external_protocols_tab.html.erb +++ b/app/views/protocols/index/_external_protocols_tab.html.erb @@ -1,92 +1,75 @@
-
+ <%= form_tag team_list_external_protocol_path(@current_team.id), + method: :get, + class: 'protocols-search-bar', + remote: true do %> +
+
+ <%= image_tag 'external_protocols/protocolsio_logo.png', + class: 'protocolsio-logo' %> + <%= t('protocols.index.external_protocols.protocolsio_title') %> + <%= hidden_field_tag 'protocol_source', 'protocolsio/v3' %> +
-
- <%= image_tag 'external_protocols/protocolsio_logo.png', - class: 'protocolsio-logo' %> - <%= t('protocols.index.external_protocols.protocolsio_title') %> +
+ + + + +
- <%= form_tag '#', - method: :get, - class: 'protocols-search-bar' do %> -
- - - - -
- <% end %> -
<%= t('protocols.index.external_protocols.sort_by.title') %> -
+
-
+ <% end %> +
-
-
+
+
<%= t('protocols.index.external_protocols.list_panel.empty_text') %>
- -
- Banana protocol (click me) -
- -
- Cut run targeted protocol (click me) -
- -
- Error protocol (click me, should default to default screen) -
+
+
<%= t('protocols.index.external_protocols.preview_panel.empty_title') %>
+

+
<%= t('protocols.index.external_protocols.preview_panel.empty_subtext') %> @@ -95,7 +78,18 @@
diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index 12816c31a..c2b8cc11f 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -203,8 +203,11 @@ class Constants base_uri: 'https://www.protocols.io/api/v3/', default_timeout: 10, debug_level: :debug, - available_order_fields: %i(title created_on), - available_order_dirs: %i(asc desc), + sort_mappings: { + alpha: { order_field: :name, order_dir: :asc }, + newest: { order_field: :date, order_dir: :desc }, + oldest: { order_field: :date, order_dir: :asc } + }, endpoints: { protocols: { default_query_params: { diff --git a/config/locales/en.yml b/config/locales/en.yml index 1fd112ddb..26145a01f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1749,6 +1749,7 @@ en: preview_panel: empty_title: 'PROTOCOL PREVIEW' empty_subtext: 'Click on the protocol in the list to preview it here' + banner_text: 'Protocol Preview' steps: completed: 'Completed' diff --git a/config/locales/protocols/en.yml b/config/locales/protocols/en.yml index b4f111968..3ac2c6280 100644 --- a/config/locales/protocols/en.yml +++ b/config/locales/protocols/en.yml @@ -2,6 +2,9 @@ en: protocol_importers: new: modal_title: 'Import protocol - %{protocol_name}' + card: + views_and_steps: '%{nr_of_views} views • %{nr_of_steps} steps' + import_button_text: 'Import...' templates: amount: title: 'Amount' @@ -44,8 +47,3 @@ en: title: 'Temperature' warning: title: 'Warning' - - - - - diff --git a/spec/fixtures/files/protocol_importers/normalized_list.json b/spec/fixtures/files/protocol_importers/normalized_list.json index d2ad475a7..9ecd9c21e 100644 --- a/spec/fixtures/files/protocol_importers/normalized_list.json +++ b/spec/fixtures/files/protocol_importers/normalized_list.json @@ -3,6 +3,7 @@ { "id": 22532, "title": "Producing rooted cassava plantlets for use in pot experiments", + "source": "protocolsio/v3", "created_on": 1556012701, "authors": "Matema Imakumbili", "nr_of_steps": 13, @@ -12,6 +13,7 @@ { "id": 832, "title": "Preparation of Virus DNA from Seawater for Metagenomics", + "source": "protocolsio/v3", "created_on": 1434495014, "authors": "Matthew Sullivan Lab", "nr_of_steps": 11, @@ -21,6 +23,7 @@ { "id": 14506, "title": "MALE CIRCUMCISION FOR PREVENTION OF HETEROSEXUAL TRANSMISSION OF HIV IN ADULT MALES IN SOWETO, what do indicators and incidence rates show?untitled protocol", + "source": "protocolsio/v3", "created_on": 1533553359, "authors": "Hillary Mukudu, Neil Martinson, Benn Sartorius", "nr_of_steps": 0, @@ -30,6 +33,7 @@ { "id": 10927, "title": "physiological and biochemical parameters", + "source": "protocolsio/v3", "created_on": 1521454618, "authors": "Amor Slama, Elhem Mallek-Maalej, Hatem Ben Mohamed, Thouraya Rhim, Leila Radhouane, Amor SLAMA", "nr_of_steps": 0, @@ -39,6 +43,7 @@ { "id": 822, "title": "Iron Chloride Precipitation of Viruses from Seawater", + "source": "protocolsio/v3", "created_on": 1434402745, "authors": "Seth John, Bonnie Poulos, Christine Schirmer", "nr_of_steps": 17, @@ -48,6 +53,7 @@ { "id": 12115, "title": "Measuring specific leaf area and water content", + "source": "protocolsio/v3", "created_on": 1526074093, "authors": "Etienne Laliberté", "nr_of_steps": 33, @@ -57,6 +63,7 @@ { "id": 1842, "title": "Purification of viral assemblages from seawater in CsCl gradients", + "source": "protocolsio/v3", "created_on": 1445287995, "authors": "Janice E. Lawrence and Grieg F. Steward", "nr_of_steps": 23, @@ -66,6 +73,7 @@ { "id": 12540, "title": "An improved primer set and PCR amplification protocol with increased specificity and sensitivity targeting the Symbiodinium ITS2 region using the SymVar primer pair", + "source": "protocolsio/v3", "created_on": 1527418313, "authors": "Benjamin Hume, Maren Ziegler, Christian Voolstra, Julie Poulain, Xavier Pochon, Sarah Romac, Emilie Boissin, Colomban de Vargas, Serge Planes, Patrick Wincker", "nr_of_steps": 1, @@ -75,6 +83,7 @@ { "id": 985, "title": "NATURAL SEAWATER-BASED PRO99 MEDIUM", + "source": "protocolsio/v3", "created_on": 1435347035, "authors": "Chisholm Lab", "nr_of_steps": 10, @@ -84,6 +93,7 @@ { "id": 1033, "title": "SN Maintenance Medium for Synechococcus", + "source": "protocolsio/v3", "created_on": 1435778857, "authors": "JB Waterbury \u0026 JM Willey", "nr_of_steps": 6,