diff --git a/app/assets/javascripts/single_page/dynamic_table.js.erb b/app/assets/javascripts/single_page/dynamic_table.js.erb index 63627432bc..619c7713ea 100644 --- a/app/assets/javascripts/single_page/dynamic_table.js.erb +++ b/app/assets/javascripts/single_page/dynamic_table.js.erb @@ -53,6 +53,9 @@ const objectInputTemp = ' { $j(e).parents("table").DataTable().row(e.closest("tr")).data()[0] = e.is(":checked") @@ -72,27 +75,53 @@ const handleSelect = (e) => { }; $j.dynamicTable.prototype = { init: function(rows, columns, options = {}) { - columns.forEach((c) => { + const studyId = options.studyId; + const assayId = options.assayId; + + columns.forEach((c) => { let linkedSamplesUrl; let cvUrl; - if (c.linked_sample_type) { + let registeredSopUrl; + + let isRegisteredSample = false; + let isCVList = false; + let isRegisteredSop = false; + let isRegisteredDataFile = false; + let isRegisteredStrain = false; + if (c.attribute_type) { + isRegisteredSample = c.attribute_type.base_type.includes("SeekSample"); + isCVList = c.attribute_type.base_type === "CVList"; + isRegisteredSop = c.attribute_type.base_type === "SeekSop"; + isRegisteredDataFile = c.attribute_type.base_type === "SeekDataFile"; + isRegisteredStrain = c.attribute_type.base_type === "SeekStrain"; + } + + if (isRegisteredSample) { linkedSamplesUrl = typeaheadSamplesUrl.replace("_LINKED_", c.linked_sample_type); const linkedSamples = retrieveLinkedSamples(linkedSamplesUrl); c.linkedSampleIds = linkedSamples.map((ls) => ls.id); } - if(c.is_cv_list && 'cv_id' in c){ + if(isCVList && 'cv_id' in c){ cvUrl = typeaheadCVUrl.replace("_CVID_", c.cv_id); } + if (isRegisteredSop) { + registeredSopUrl = typeaheadSopsUrl.replace('__STUDY_ID__', options.studyId).replace('__ASSAY_ID__', options.assayId); + } + c["render"] = function(data_, type, full, meta) { let sanitizedData = sanitizeData(data_); let data; - if(c.linked_sample_type){ + if(isRegisteredSample){ data = sanitizedData && Array.isArray(sanitizedData) ? sanitizedData : [sanitizedData]; data = data[0]?.id ? data : []; return registeredSamplesObjectsInput(c, data, options, linkedSamplesUrl); - } else if(c.is_cv_list && sanitizedData !== "#HIDDEN"){ + } else if (isRegisteredSop) { + data = sanitizedData && Array.isArray(sanitizedData) ? sanitizedData : [sanitizedData]; + data = data[0]?.id ? data : []; + return simpleObjectsInput(c, data, options, registeredSopUrl); + } else if(isCVList && sanitizedData !== "#HIDDEN"){ data = sanitizedData && Array.isArray(sanitizedData) ? sanitizedData : [sanitizedData]; data = data.map((e) => { if (e?.id){ @@ -102,6 +131,14 @@ const handleSelect = (e) => { } }); return cvListObjectsInput(c, data, options, cvUrl); + } else if (isRegisteredDataFile) { + data = sanitizedData && Array.isArray(sanitizedData) ? sanitizedData : [sanitizedData]; + data = data[0]?.id ? data : []; + return simpleObjectsInput(c, data, options, typeaheadDataFilesUrl); + } else if (isRegisteredStrain) { + data = sanitizedData && Array.isArray(sanitizedData) ? sanitizedData : [sanitizedData]; + data = data[0]?.id ? data : []; + return simpleObjectsInput(c, data, options, typeaheadStrainsUrl); } else if (sanitizedData === "#HIDDEN") { return "Hidden"; } else { @@ -630,10 +667,34 @@ function cvListObjectsInput(column, data, options, url){ .replace('_EXTRACLASS_', extraClass) .replace('_TITLE_', titleText) .replace('_LIMIT?_', '') - .replace('_ALLOW_FREE_TEXT_', allowNewItems); + .replace('_ALLOW_FREE_TEXT_', allowNewItems.toString()); } } +function simpleObjectsInput(column, data, options, url) { + const existingOptions = data.map((e) => ``); + if (options.readonly) { + return data.map((e) => `${sanitizeData(e)}`).join(" "); + } else { + const typeaheadTemplate = 'typeahead/single_pages_samples'; + const objectInputName = data.map((e) => sanitizeData(e)).join('-') + '-' + crypto.randomUUID(); + const extraClass = ''; + const titleText = ''; + const allowNewItems = false; + setTimeout(ObjectsInput.init); + + return objectInputTemp + .replace(/_NAME_/g, objectInputName) + .replace('_TYPEHEAD_', typeaheadTemplate) + .replace('_URL_', url) + .replace('_OPTIONS_', existingOptions) + .replace('_EXTRACLASS_', extraClass) + .replace('_TITLE_', titleText) + .replace('_LIMIT?_', '1') + .replace('_ALLOW_FREE_TEXT_', allowNewItems.toString()); + } +} + const handleFailure = (table, res) => { const errors = new Set(); errors.add("The operation can not be performed for one or some samples. The red cells indicate unacceptable values."); diff --git a/app/controllers/sops_controller.rb b/app/controllers/sops_controller.rb index e6e0e803c5..244adf892b 100644 --- a/app/controllers/sops_controller.rb +++ b/app/controllers/sops_controller.rb @@ -56,6 +56,26 @@ def update end end + def dynamic_table_typeahead + return if params[:study_id].blank? && params[:assay_id].blank? + + query = params[:query] || '' + asset = if params[:study_id].present? + Study.authorized_for('view').detect { |study| study.id.to_s == params[:study_id] } + else + Assay.authorized_for('view').detect { |assay| assay.id.to_s == params[:assay_id] } + end + + sops = asset&.sops || [] + filtered_sops = sops.select { |sop| sop.title&.downcase&.include?(query.downcase) } + items = filtered_sops.collect { |sop| { id: sop.id, text: sop.title } } + + respond_to do |format| + format.json { render json: { results: items }.to_json } + end + end + + private def sop_params diff --git a/app/helpers/dynamic_table_helper.rb b/app/helpers/dynamic_table_helper.rb index 47ec75dc4a..32ed5b903f 100644 --- a/app/helpers/dynamic_table_helper.rb +++ b/app/helpers/dynamic_table_helper.rb @@ -99,15 +99,17 @@ def transform_registered_sample_single(json_metadata, input_key) def dt_cols(sample_type) attribs = sample_type.sample_attributes.map do |a| attribute = { title: a.title, name: sample_type.id.to_s, required: a.required, description: a.description, - is_title: a.is_title } - attribute.merge!({ cv_id: a.sample_controlled_vocab_id }) unless a.sample_controlled_vocab_id.blank? - is_seek_sample = a.sample_attribute_type.seek_sample? - is_seek_multi_sample = a.sample_attribute_type.seek_sample_multi? - is_cv_list = a.sample_attribute_type.seek_cv_list? - cv_allows_free_text = a.allow_cv_free_text - attribute.merge!({ multi_link: true, linked_sample_type: a.linked_sample_type_id }) if is_seek_multi_sample - attribute.merge!({ multi_link: false, linked_sample_type: a.linked_sample_type_id }) if is_seek_sample - attribute.merge!({ is_cv_list: true, cv_allows_free_text:}) if is_cv_list + is_title: a.is_title, attribute_type: a.sample_attribute_type } + + if a.sample_attribute_type&.controlled_vocab? + cv_allows_free_text = a.allow_cv_free_text + attribute.merge!({ cv_allows_free_text: cv_allows_free_text, cv_id: a.sample_controlled_vocab_id }) + end + + if a.sample_attribute_type&.seek_sample_multi? || a.sample_attribute_type&.seek_sample? + attribute.merge!({ multi_link: a.sample_attribute_type&.seek_sample_multi?, linked_sample_type: a.linked_sample_type_id }) + end + attribute end (dt_default_cols(sample_type.id.to_s) + attribs).flatten diff --git a/app/views/isa_studies/_study_samples.html.erb b/app/views/isa_studies/_study_samples.html.erb index 299ec76edd..fd1d3a5827 100644 --- a/app/views/isa_studies/_study_samples.html.erb +++ b/app/views/isa_studies/_study_samples.html.erb @@ -24,6 +24,7 @@ window.sampleDynamicTable = new $j.dynamicTable('#study-samples-table') const elem = $j("#btn_save_sample") const options = { + studyId: <%= study&.id %>, ajax:{ url: dynamicTableDataPath, data: function(d) { diff --git a/config/routes.rb b/config/routes.rb index 179c4a7514..e92f618b19 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -532,6 +532,9 @@ end resources :sops, concerns: [:has_content_blobs, :publishable, :has_doi, :has_versions, :asset, :explorable_spreadsheet] do + collection do + get :dynamic_table_typeahead + end resources :people, :programmes, :projects, :investigations, :assays, :samples, :studies, :publications, :events, :workflows, :collections, only: [:index] end diff --git a/lib/isa_exporter.rb b/lib/isa_exporter.rb index 853cb4f489..70fb45faad 100644 --- a/lib/isa_exporter.rb +++ b/lib/isa_exporter.rb @@ -103,44 +103,69 @@ def convert_study(study) isa_study[:people] = people isa_study[:studyDesignDescriptors] = [] isa_study[:characteristicCategories] = convert_characteristic_categories(study) + protocols_maps = fetch_study_protocols(study) + isa_study[:protocols] = protocols_maps.map { |p| p[:protocols] }.compact.flatten isa_study[:materials] = { sources: convert_materials_sources(study.sample_types.first), samples: convert_materials_samples(study.sample_types.second) } - protocols = [] - with_tag_protocol_study = study.sample_types.second.sample_attributes.detect { |sa| sa.isa_tag&.isa_protocol? } - with_tag_parameter_value_study = - study.sample_types.second.sample_attributes.select { |sa| sa.isa_tag&.isa_parameter_value? } - raise "Protocol ISA tag not found in #{t(:study)} #{study.id}" if with_tag_protocol_study.blank? - # raise "The Study with the title '#{study.title}' does not have any SOP" if study.sops.blank? - protocols << convert_protocol(study.sops, study.id, with_tag_protocol_study, with_tag_parameter_value_study) - - study.assay_streams.map(&:child_assays).flatten.each do |a| - # There should be only one attribute with isa_tag == protocol - protocol_attribute = a.sample_type.sample_attributes.detect { |sa| sa.isa_tag&.isa_protocol? } - with_tag_parameter_value = a.sample_type.sample_attributes.select { |sa| sa.isa_tag&.isa_parameter_value? } - raise "Protocol ISA tag not found in #{t(:assay)} #{a.id}" if protocol_attribute.blank? - - # raise "The #{t(:study)} with the title '#{study.title}' does not have an SOP" if a.sops.blank? - protocols << convert_protocol(a.sops, a.id, protocol_attribute, with_tag_parameter_value) - end - isa_study[:protocols] = protocols - - isa_study[:processSequence] = convert_process_sequence(study.sample_types.second, study.sops.map(&:id).join("_"), study.id) + study_protocols_map = protocols_maps.detect { |pm| pm[:sample_type_id] == study.sample_types.second.id } + isa_study[:processSequence] = convert_process_sequence(study.sample_types.second, study_protocols_map) unless study.assays.all?(&:is_isa_json_compliant?) raise "All assays in study `#{study.title}` should be ISA-JSON compliant." end - isa_study[:assays] = study.assay_streams.map { |assay_stream| convert_assays(assay_stream) }.compact + isa_study[:assays] = study.assay_streams.map { |assay_stream| convert_assays(assay_stream, protocols_maps) }.compact isa_study[:factors] = [] isa_study[:unitCategories] = [] isa_study end + def fetch_study_protocols(study) + protocols_maps = [] + + # Get all sample types from study and its assays + sample_type_objcts = [{ sample_type: study.sample_types.second, isa: 'study', isa_id: study.id }] + sample_type_objcts += study.assay_streams.map(&:child_assays).flatten.map(&:sample_type).map do |sample_type| + { sample_type: sample_type, isa: 'assay', isa_id: sample_type.assays.first.id } + end + + sample_type_objcts.each do |sample_type_object| + sample_type, isa, isa_id = sample_type_object[:sample_type], sample_type_object[:isa], sample_type_object[:isa_id] + protocol_attribute = sample_type.sample_attributes.detect { |sa| sa.isa_tag&.isa_protocol? } + parameter_attributes = sample_type.sample_attributes.select { |sa| sa.isa_tag&.isa_parameter_value? }.compact + next if protocol_attribute.blank? + + is_registered_sop = protocol_attribute.sample_attribute_type.seek_sop? + # Get used SOPs + if is_registered_sop + used_sops = sample_type.samples.map do |sample| + sop = sample.get_attribute_value(protocol_attribute) + raise "Sample {#{sample.id}: #{sample.title}} has no registered SOP as protocol" if sop.blank? + Sop.find_by_id(sop['id']) + end.uniq + else + used_sops = sample_type.samples.map do |sample| + protocol_title = sample.get_attribute_value(protocol_attribute) + raise "Sample {#{sample.id}: #{sample.title}} has no protocol" if protocol_title.blank? + { id: protocol_attribute.id, title: protocol_title, description: '' } + end.uniq + end + + # generate & append to protcols + protocols_maps.append({ protocols: used_sops.map do |sop| + id = "#protocol/#{isa}_#{isa_id}_#{sop[:id]}" + convert_protocol(sop, id, protocol_attribute, parameter_attributes) + end, isa: isa, isa_id: isa_id, sample_type_id: sample_type.id }) + end + + protocols_maps + end + def convert_annotation(term_uri) isa_annotation = {} term = term_uri.split('#')[1] @@ -179,7 +204,7 @@ def convert_assay_comments(assay_stream) assay_comments.compact end - def convert_assays(assay_stream) + def convert_assays(assay_stream, protocols_maps) child_assays = assay_stream.child_assays return unless assay_stream.can_view?(@current_user) return unless child_assays.all? { |a| a.can_view?(@current_user) } @@ -215,7 +240,10 @@ def convert_assays(assay_stream) otherMaterials: convert_other_materials(all_sample_types) } isa_assay[:processSequence] = - child_assays.map { |a| convert_process_sequence(a.sample_type, a.sops.map(&:id).join("_"), a.id) }.flatten + child_assays.map do |assay| + assay_protocols_map = protocols_maps.detect { |pm| pm[:sample_type_id] == assay.sample_type.id } + convert_process_sequence(assay.sample_type,assay_protocols_map) + end.flatten isa_assay[:dataFiles] = convert_data_files(all_sample_types) isa_assay[:unitCategories] = [] isa_assay @@ -286,34 +314,34 @@ def convert_ontologies source_ontologies.uniq.map { |s| { name: s, file: '', version: '', description: '' } } end - def convert_protocol(sops, id, protocol, parameter_values) + def convert_protocol(sop, id, protocol_attrbute, parameter_attributes) isa_protocol = {} - isa_protocol['@id'] = "#protocol/#{sops.map(&:id).join("-")}_#{id}" - isa_protocol[:name] = protocol.title # sop.title + isa_protocol['@id'] = id + isa_protocol[:name] = sop[:title] - ontology = get_ontology_details(protocol, protocol.title, false) + ontology = get_ontology_details(protocol_attrbute, protocol_attrbute.title, false) isa_protocol[:protocolType] = { - annotationValue: protocol.title, + annotationValue: protocol_attrbute.title, termAccession: ontology[:termAccession], termSource: ontology[:termSource] } - isa_protocol[:description] = sops&.first&.description || '' + isa_protocol[:description] = sop[:description] || '' isa_protocol[:uri] = ontology[:termAccession] isa_protocol[:version] = '' isa_protocol[:parameters] = - parameter_values.map do |parameter_value| + parameter_attributes.map do |parameter| parameter_value_ontology = - if parameter_value.pid.present? - get_ontology_details(parameter_value, parameter_value.title, false) + if parameter.pid.present? + get_ontology_details(parameter, parameter.title, false) else { termAccession: '', termSource: '' } end { - '@id': "#parameter/#{parameter_value.id}", + '@id': "#parameter/#{parameter.id}", parameterName: { - annotationValue: parameter_value.title, + annotationValue: parameter.title, termAccession: parameter_value_ontology[:termAccession], termSource: parameter_value_ontology[:termSource] } @@ -352,13 +380,14 @@ def convert_materials_samples(sample_type) with_tag_sample = sample_type.sample_attributes.detect { |sa| sa.isa_tag&.isa_sample? } with_tag_sample_characteristic = sample_type.sample_attributes.select { |sa| sa.isa_tag&.isa_sample_characteristic? } - seek_sample_multi_attribute = sample_type.sample_attributes.detect(&:seek_sample_multi?) + input_attribute = sample_type.sample_attributes.detect(&:input_attribute?) sample_type.samples.map do |s| + # To Do: FactorValues is empty. This relates to # 1869: https://github.com/seek4science/seek/issues/1869 if s.can_view?(@current_user) { '@id': "#sample/#{s.id}", name: s.get_attribute_value(with_tag_sample), - derivesFrom: extract_sample_ids(s.get_attribute_value(seek_sample_multi_attribute), 'source'), + derivesFrom: extract_sample_ids(s.get_attribute_value(input_attribute), 'source'), characteristics: convert_characteristics(s, with_tag_sample_characteristic), factorValues: [ { @@ -378,7 +407,7 @@ def convert_materials_samples(sample_type) { '@id': "#sample/HIDDEN", name: '', - derivesFrom: extract_sample_ids(s.get_attribute_value(seek_sample_multi_attribute), 'source'), + derivesFrom: extract_sample_ids(s.get_attribute_value(input_attribute), 'source'), characteristics: [], factorValues: [ { @@ -399,12 +428,12 @@ def convert_materials_samples(sample_type) end def convert_characteristics(sample, attributes) - attributes.map do |c| - value = sample.can_view?(@current_user) ? (sample.get_attribute_value(c) || '') : '' - ontology = get_ontology_details(c, value, true) + attributes.map do |attribute| + value = convert_attribute_value(sample, attribute) + ontology = get_ontology_details(attribute, value, true) { category: { - '@id': normalize_id("#characteristic_category/#{c.title}_#{c.id}") + '@id': normalize_id("#characteristic_category/#{attribute.title}_#{attribute.id}") }, value: { annotationValue: value, @@ -416,6 +445,18 @@ def convert_characteristics(sample, attributes) end end + def convert_attribute_value(sample, attribute) + return '' unless sample.can_view?(@current_user) + + if attribute.sample_attribute_type.seek_sample? || attribute.sample_attribute_type.seek_sample_multi? || attribute.sample_attribute_type.seek_strain? || attribute.sample_attribute_type.seek_data_file? || attribute.sample_attribute_type.seek_sop? + sample.get_attribute_value(attribute).to_json || '' + elsif attribute.sample_attribute_type.base_type == Seek::Samples::BaseType::CV_LIST + sample.get_attribute_value(attribute).join(', ') + else + sample.get_attribute_value(attribute) || '' + end + end + def convert_characteristic_categories(study = nil, assays = nil) attributes = [] if study @@ -440,7 +481,7 @@ def convert_characteristic_categories(study = nil, assays = nil) end end - def convert_process_sequence(sample_type, sop_ids, id) + def convert_process_sequence(sample_type, protocols_map) # This method is meant to be used for both Studies and Assays return [] unless sample_type.samples.any? @@ -456,25 +497,28 @@ def convert_process_sequence(sample_type, sop_ids, id) # should be in a different process in the processSequence samples_grouped_by_input_and_parameter_value = group_samples_by_input_and_parameter_value(sample_type) result = [] - samples_grouped_by_input_and_parameter_value.map do |input_ids, samples_group| - output_ids = samples_group.pluck(:id).join('_') + samples_grouped_by_input_and_parameter_value.map do |sample_group| + # It's fine to take the first output to get the protocol name since the outputs are grouped by protocol as well. + protocols, isa, isa_id, _sample_type_id = protocols_map.values_at(:protocols, :isa, :isa_id, :sample_type_id) + executed_protocol = protocols_map[:protocols].detect { |p| p[:name] == sample_group[:executed_protocol][:title] } + output_ids = sample_group[:outputs].map { |s| s[:id] }.join('_') process = { '@id': normalize_id("#process/#{protocol_attribute.title}/#{output_ids}"), name: '', executesProtocol: { - '@id': "#protocol/#{sop_ids}_#{id}" + '@id': executed_protocol["@id"] }, - parameterValues: convert_parameter_values(samples_group, isa_parameter_value_attributes), + parameterValues: convert_parameter_values(sample_group[:outputs], isa_parameter_value_attributes), performer: '', date: '', - inputs: process_sequence_input(input_ids.first, type), - outputs: process_sequence_output(samples_group) + inputs: process_sequence_input(sample_group[:inputs], type), + outputs: process_sequence_output(sample_group[:outputs]) } # Study processes don't have a previousProcess and nextProcess unless type == 'source' process.merge!({ - previousProcess: previous_process(samples_group), - nextProcess: next_process(samples_group) + previousProcess: previous_process(sample_group[:outputs]), + nextProcess: next_process(sample_group[:outputs]) }) end result.push(process) @@ -567,6 +611,15 @@ def export private + def detect_material(sample_type) + sample_type.sample_attributes.detect do |sa| + [ + Seek::ISA::TagType::SOURCE, + Seek::ISA::TagType::SAMPLE, + Seek::ISA::TagType::OTHER_MATERIAL + ].include? sa.isa_tag&.title + end + end def detect_sample(sample_type) sample_type.sample_attributes.detect { |sa| sa.isa_tag&.isa_sample? } end @@ -595,8 +648,8 @@ def detect_other_material(sample_type) sample_type.sample_attributes.detect { |sa| sa.isa_tag&.isa_other_material? } end - def detect_sample_multi(sample_type) - sample_type.sample_attributes.detect(&:seek_sample_multi?) + def detect_input_attribute(sample_type) + sample_type.sample_attributes.detect(&:input_attribute?) end def next_process(samples_hash) @@ -642,12 +695,27 @@ def group_samples_by_input_and_parameter_value(sample_type) }.merge(metadata).transform_keys!(&:to_sym) end - input_attribute = detect_sample_multi(sample_type)&.title&.to_sym - parameter_value_attributes = select_parameter_values(sample_type).map(&:title).map(&:to_sym) - group_attributes = parameter_value_attributes.unshift(input_attribute) + input_attribute_name = detect_input_attribute(sample_type)&.title&.to_sym + parameter_value_attribute_names = select_parameter_values(sample_type).map(&:title).map(&:to_sym) + protocol_attribute_name = detect_protocol(sample_type)&.title&.to_sym + material_attribute_name = detect_material(sample_type)&.title&.to_sym + group_attribute_names = parameter_value_attribute_names.unshift(protocol_attribute_name).unshift(input_attribute_name) + + # grouped_samples = samples_metadata.group_by { |smd| group_attributes.map { |attr| smd[attr] } } + sample_groups = samples_metadata.group_by { |smd| group_attribute_names.map { |attr| smd[attr] }.flatten }.map { |_key, val| val } - grouped_samples = samples_metadata.group_by { |smd| group_attributes.map { |attr| smd[attr] } } - grouped_samples.transform_keys { |key| group_id(key) } + sample_groups.map do |sample_group| + inputs = sample_group.first[input_attribute_name] + executed_protocol = detect_protocol(sample_type).sample_attribute_type.seek_sop? ? sample_group.first[protocol_attribute_name] : { id: detect_protocol(sample_type)&.id, title: sample_group.first[protocol_attribute_name] } + parameter_values = sample_group.first.slice(*parameter_value_attribute_names) + outputs = sample_group.map { |sample| {id: sample[:id], title: sample[material_attribute_name] } } + { + inputs: inputs.map { |input| input.transform_keys!(&:to_sym) }, + executed_protocol: executed_protocol.transform_keys!(&:to_sym), + parameter_values: parameter_values.transform_keys!(&:to_sym), + outputs: outputs.map { |input| input.transform_keys!(&:to_sym) } + } + end end def process_sequence_input(inputs, type) @@ -688,7 +756,7 @@ def convert_parameter_values(sample_group_hash, isa_parameter_value_attributes) # So retrieving the first one in the group should be fine. sample = Sample.find(sample_group_hash.first[:id]) isa_parameter_value_attributes.map do |p| - value = sample.get_attribute_value(p) || '' + value = convert_attribute_value(sample, p) ontology = get_ontology_details(p, value, true) { category: { diff --git a/test/factories/assays.rb b/test/factories/assays.rb index 24f95f1ae0..e932c7b282 100644 --- a/test/factories/assays.rb +++ b/test/factories/assays.rb @@ -101,21 +101,51 @@ assets_creators { [AssetsCreator.new(affiliation: 'University of Somewhere', creator: FactoryBot.create(:person, first_name: 'Some', last_name: 'One'))] } end - factory(:isa_json_compliant_assay, parent: :assay) do - title { 'ISA JSON compliant assay' } - description { 'An assay linked to an ISA JSON compliant study and a sample type' } + factory(:assay_stream, parent: :assay_base) do + sequence(:title) { |n| "Assay Stream #{n}" } + description { 'A holder assay holding multiple child assays' } + association :assay_class, factory: :assay_stream_class after(:build) do |assay| + assay.study ||= FactoryBot.create(:isa_json_compliant_study, contributor: assay.contributor) + end + end + + factory(:isa_json_compliant_material_assay, parent: :assay) do + transient do + linked_sample_type { nil } + end + sequence(:title) { |n| "ISA JSON compliant material assay #{n}" } + description { 'An assay linked to an ISA JSON compliant study and a sample type' } + after(:build) do |assay, eval| assay.study ||= FactoryBot.create(:isa_json_compliant_study) - assay.sample_type = FactoryBot.create(:isa_assay_material_sample_type, linked_sample_type: assay.study.sample_types.last) + assay.sample_type = FactoryBot.create(:isa_assay_material_sample_type, linked_sample_type: eval.linked_sample_type) end end - factory(:assay_stream, parent: :assay_base) do - title { 'Assay Stream' } - description { 'A holder assay holding multiple child assays' } + factory(:isa_json_compliant_data_file_assay, parent: :assay) do + transient do + linked_sample_type { nil } + end + sequence(:title) { |n| "ISA JSON compliant data file assay #{n}" } + description { 'An assay linked to an ISA JSON compliant study and a sample type' } + after(:build) do |assay, eval| + assay.study ||= FactoryBot.create(:isa_json_compliant_study) + assay.sample_type = FactoryBot.create(:isa_assay_data_file_sample_type, linked_sample_type: eval.linked_sample_type) + end + end + + factory(:complete_assay_stream, parent: :assay_stream) do + transient do + sample_collection_sample_type { nil } + end + sequence(:title) { |n| "Complete Assay Stream #{n}" } + description { 'An assay stream populated with assays' } association :assay_class, factory: :assay_stream_class - after(:build) do |assay| - assay.study ||= FactoryBot.create(:isa_json_compliant_study, contributor: assay.contributor) + after(:build) do |assay_stream, eval| + first_material_assay = FactoryBot.create(:isa_json_compliant_material_assay, title: 'Pre-treatment', linked_sample_type: eval.sample_collection_sample_type, study: assay_stream.study, contributor: assay_stream.contributor) + second_material_assay = FactoryBot.create(:isa_json_compliant_material_assay, title: 'Extraction', linked_sample_type: first_material_assay.sample_type, study: assay_stream.study, contributor: assay_stream.contributor) + data_file_assay = FactoryBot.create(:isa_json_compliant_data_file_assay, title: 'Measurement', linked_sample_type: second_material_assay.sample_type, study: assay_stream.study, contributor: assay_stream.contributor) + assay_stream.child_assays = [first_material_assay, second_material_assay, data_file_assay] end end diff --git a/test/functional/sops_controller_test.rb b/test/functional/sops_controller_test.rb index 829146d0bc..f7e884f9bb 100644 --- a/test/functional/sops_controller_test.rb +++ b/test/functional/sops_controller_test.rb @@ -2140,6 +2140,52 @@ def test_editing_doesnt_change_contributor assert_equal 0, policy.permissions.count end + test 'dynamic table typeahead should return sops only linked to studies or assays' do + person = FactoryBot.create(:person) + project = person.projects.first + study_sops = (1..10).collect.each { |i| FactoryBot.create(:sop, contributor: person, projects: [project], title: "My study level protocol nr. #{i}") } + + # 5 sops unrelated to study + (11..15).collect.each { |i| FactoryBot.create(:sop, contributor: person, projects: [project], title: "My protocol nr. #{i}") } + + # Project has a total of 15 sops + assert_equal project.sops.count, 15 + + investigation = FactoryBot.create(:investigation, contributor: person, projects: [project]) + study = FactoryBot.create(:study, contributor: person, investigation: investigation, sops: study_sops) + login_as(person) + + # No query + # Should only return 10 sops linked to study + get :dynamic_table_typeahead, params: { study_id: study.id }, format: :json + results = JSON.parse(response.body)['results'] + assert_equal results.count, 10 + + # Query '1' + # Should return 2 sops linked to study: nr. 1 and 10 + get :dynamic_table_typeahead, params: { study_id: study.id, query: '1' }, format: :json + results = JSON.parse(response.body)['results'] + assert_equal results.count, 2 + + assay_sops = (16..20).collect.each { |i| FactoryBot.create(:sop, contributor: person, projects: [project], title: "My assay level protocol nr. #{i}") } + assay = FactoryBot.create(:assay, contributor: person, study: study, sops: assay_sops) + + # Project has now a total of 20 sops + assert_equal project.sops.count, 20 + + # Query 'assay' + # Should return 5 sops linked to assay: nr. 16 to 20 + get :dynamic_table_typeahead, params: { assay_id: assay.id, query: 'assay' }, format: :json + results = JSON.parse(response.body)['results'] + assert_equal results.count, 5 + + # Query '12' + # Should return 0 sops linked to assay + get :dynamic_table_typeahead, params: { assay_id: assay.id, query: '12' }, format: :json + results = JSON.parse(response.body)['results'] + assert_equal results.count, 0 + end + private def doi_citation_mock diff --git a/test/unit/assay_test.rb b/test/unit/assay_test.rb index 1938706e20..6adb8e37f6 100644 --- a/test/unit/assay_test.rb +++ b/test/unit/assay_test.rb @@ -765,7 +765,7 @@ def new_valid_assay assay_stream = FactoryBot.create(:assay_stream, study: isa_json_compliant_study) assert assay_stream.is_isa_json_compliant? - isa_json_compliant_assay = FactoryBot.create(:isa_json_compliant_assay, study: isa_json_compliant_study) + isa_json_compliant_assay = FactoryBot.create(:isa_json_compliant_material_assay, study: isa_json_compliant_study, linked_sample_type: isa_json_compliant_study.sample_types.second) assert isa_json_compliant_assay.is_isa_json_compliant? end @@ -791,9 +791,10 @@ def new_valid_assay def_assay = FactoryBot.create(:assay, study:def_study) assert_nil def_assay.previous_linked_sample_type - first_isa_assay = FactoryBot.create(:isa_json_compliant_assay, + first_isa_assay = FactoryBot.create(:isa_json_compliant_material_assay, assay_stream: assay_stream , - study: isa_study) + study: isa_study, + linked_sample_type: isa_study.sample_types.second) assert_equal first_isa_assay.previous_linked_sample_type, isa_study.sample_types.second data_file_sample_type = FactoryBot.create(:isa_assay_data_file_sample_type, @@ -814,7 +815,7 @@ def new_valid_assay def_assay = FactoryBot.create(:assay, study:def_study) assay_stream = FactoryBot.create(:assay_stream, study: isa_study) - first_isa_assay = FactoryBot.create(:isa_json_compliant_assay, study: isa_study) + first_isa_assay = FactoryBot.create(:isa_json_compliant_material_assay, study: isa_study, linked_sample_type: isa_study.sample_types.second, assay_stream: assay_stream) data_file_sample_type = FactoryBot.create(:isa_assay_data_file_sample_type, linked_sample_type: first_isa_assay.sample_type) second_isa_assay = FactoryBot.create(:assay, @@ -835,7 +836,7 @@ def new_valid_assay def_assay = FactoryBot.create(:assay, study:def_study) assay_stream = FactoryBot.create(:assay_stream, study: isa_study) - first_isa_assay = FactoryBot.create(:isa_json_compliant_assay, study: isa_study, assay_stream: assay_stream ) + first_isa_assay = FactoryBot.create(:isa_json_compliant_material_assay, study: isa_study, assay_stream: assay_stream, linked_sample_type: isa_study.sample_types.second) data_file_sample_type = FactoryBot.create(:isa_assay_data_file_sample_type, linked_sample_type: first_isa_assay.sample_type) second_isa_assay = FactoryBot.create(:assay, diff --git a/test/unit/isa_exporter_test.rb b/test/unit/isa_exporter_test.rb index c4cd891778..7467640e3d 100644 --- a/test/unit/isa_exporter_test.rb +++ b/test/unit/isa_exporter_test.rb @@ -1,6 +1,10 @@ require 'test_helper' class IsaExporterTest < ActionController::TestCase + def setup + @seek_sop_type = SampleAttributeType.find_by(base_type: Seek::Samples::BaseType::SEEK_SOP) || FactoryBot.create(:sop_sample_attribute_type) + end + test 'find sample origin' do current_user = FactoryBot.create(:user) controller = IsaExporter::Exporter.new(FactoryBot.create(:investigation), current_user) @@ -83,4 +87,75 @@ class IsaExporterTest < ActionController::TestCase child_3.reload assert_equal [child_1.id, child_2_another_parent.id], controller.send(:find_sample_origin, [child_3], 1) end + + test 'should export registered sops correctly' do + person = FactoryBot.create(:person) + investigation = FactoryBot.create(:investigation, contributor: person, projects: [person.projects.first], is_isa_json_compliant: true) + sample_collection_sop = FactoryBot.create(:sop, contributor: person, title: 'Sample collection protocol', projects: [investigation.projects.first]) + study = FactoryBot.create(:isa_json_compliant_study, contributor: person, investigation: investigation, sops: [sample_collection_sop]) + + # Create sources + source_sample_type = study.sample_types.first + (0..10).each do |i| + FactoryBot.create(:sample, title: "Source #{i}", contributor: person, sample_type: source_sample_type, project_ids: [study.projects.first.id], + data: { 'Source Name': "Source #{i}", 'Source Characteristic 1': 'source 1 characteristic 1', 'Source Characteristic 2': 'Bramley' }) + end + + sample_collection_sample_type = study.sample_types.last + sample_collection_sample_type.sample_attributes.detect { |sa| sa.isa_tag&.isa_protocol? }.update_column('sample_attribute_type_id', @seek_sop_type.id) + (0..10).each do |i| + FactoryBot.create(:sample, title: "Sample #{i}", contributor: person, sample_type: sample_collection_sample_type, project_ids: [study.projects.first.id], + data: { 'Sample Name': "Sample #{i}", 'sample collection': sample_collection_sop, Input: "Source #{i}", 'sample characteristic 1': 'value sample 1', 'sample collection parameter value 1': 'value 1' }) + end + + assay_stream = FactoryBot.create(:complete_assay_stream, study: study, contributor: person, sample_collection_sample_type: sample_collection_sample_type) + + pre_treatment_assay = assay_stream.child_assays.detect { |assay| assay.title.include? "Pre-treatment" } + pre_treatment_sample_type = pre_treatment_assay.sample_type + 0.upto(10) do |i| + FactoryBot.create(:sample, title: "Pre-treatment #{i}", contributor: person, sample_type: pre_treatment_sample_type, project_ids: [study.projects.first.id], + data: { 'Extract Name': "Pre-treatment #{i}", Input: "Sample #{i}", 'Protocol Assay 1': "Pre-treatment protocol", 'other material characteristic 1': "Pre-treatment #{i} characteristic 1", 'Assay 1 parameter value 1': "Pre-treatment #{i} Parameter value 1" }) + end + + extraction_assay = assay_stream.child_assays.detect { |assay| assay.title.include? "Extraction" } + extraction_assay_sample_type = extraction_assay.sample_type + 0.upto(10) do |i| + FactoryBot.create(:sample, title: "Extraction #{i}", contributor: person, sample_type: extraction_assay_sample_type, project_ids: [study.projects.first.id], + data: { 'Extract Name': "Extract #{i}", Input: "Pre-treatment #{i}", 'Protocol Assay 1': "Extraction protocol", 'other material characteristic 1': "Extract #{i} characteristic 1", 'Assay 1 parameter value 1': "Extract #{i} Parameter value 1" }) + end + + measurement_assay = assay_stream.child_assays.detect { |assay| assay.title.include? "Measurement" } + measurement_assay_sample_type = measurement_assay.sample_type + 0.upto(10) do |i| + protocol_name = i%3 == 0 ? "Measurement protocol 1" : "Measurement protocol 2" + FactoryBot.create(:sample, title: "Measurement #{i}", contributor: person, sample_type: measurement_assay_sample_type, project_ids: [study.projects.first.id], + data: { 'File Name': "Measurement #{i}", Input: "Extract #{i}", 'Protocol Assay 2': protocol_name, 'Assay 2 parameter value 1': "Measurement #{i} Parameter 1", 'Data file comment 1': "Measurement #{i} comment 1" }) + end + + isa = JSON.parse(IsaExporter::Exporter.new(investigation, person.user).export) + assert_not_nil isa + + # Check the number of studies + assert_equal isa['studies'].count, 1 + + # Check the number of assay streams + assert_equal isa['studies'][0]['assays'].count, 1 + + # check the study protocols + # Study holds a registered SOP as protocol. All samples use the "Sample collection protocol". + # The pre-treatment assay has free text to describe the protocol. All samples use the "Pre-treatment protocol". + # The extraction assay has free text to describe the protocol. All samples use the "Extraction protocol". + # The measurement assay has free text to describe the protocol. Some samples use the "Measurement protocol 1", while others use "Measurement protocol 2". + # This gives a total of 5 protocols. + protocols = isa['studies'][0]['protocols'] + assert_equal protocols.count, 5 + + # check the parameters + parameter_attributes = [sample_collection_sample_type, pre_treatment_sample_type, extraction_assay_sample_type, measurement_assay_sample_type].map do |sample_type| + sample_type.sample_attributes.select { |sa| sa.isa_tag&.isa_parameter_value? }.map(&:title) + end.flatten.compact.uniq + isa_parameters = protocols.map { |protocol| protocol['parameters'] }.flatten.compact.map { |parameter| parameter['parameterName']['annotationValue'] }.compact.uniq + assert_equal isa_parameters.count, parameter_attributes.count + assert parameter_attributes.all? { |title| isa_parameters.include? title } + end end