diff --git a/app/api/chemotion/version_api.rb b/app/api/chemotion/version_api.rb index d9080ca990..eecb11faaf 100644 --- a/app/api/chemotion/version_api.rb +++ b/app/api/chemotion/version_api.rb @@ -17,39 +17,8 @@ class VersionAPI < Grape::API route_param :id do get do - # find specific sample and load only required data - sample = Sample.select(:id, :name, :log_data, :updated_at).find(params[:id]) - - analyses = sample.analyses.flat_map { |analysis| analysis.self_and_descendants.select(:id, :name, :updated_at, :log_data) } - - # create cache key for sample - timestamp = [ - sample.updated_at, - analyses.map(&:updated_at).max, - Attachment.where(attachable_id: analyses.map(&:id), attachable_type: 'Container').maximum(:updated_at) - ].reject(&:nil?).max.to_i - cache_key = "versions/samples/#{sample.id}/#{timestamp}" - - # cache processed and sorted versions to speed up pagination - versions = Rails.cache.fetch cache_key do - all_versions = sample.versions_hash - all_versions += sample.residues.select(:sample_id, :log_data).flat_map do |residue| - residue.versions_hash(sample.name) - end - all_versions += sample.elemental_compositions.select(:sample_id, :log_data).flat_map do |elemental_composition| - elemental_composition.versions_hash(sample.name) - end - - analyses.each do |analysis| - all_versions += analysis.versions_hash - all_versions += analysis.attachments.select(:attachable_id, :attachable_type, :filename, :log_data).flat_map do |attachment| - attachment.versions_hash(attachment.filename) - end - end - - all_versions.sort_by! { |version| -version['t'].to_i } # sort versions with the latest changes in the first place - .each_with_index { |record, index| record['v'] = all_versions.length - index } # adjust v to be uniq and in right order - end + sample = Sample.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(sample) { versions: paginate(Kaminari.paginate_array(versions)) } end @@ -67,60 +36,63 @@ class VersionAPI < Grape::API route_param :id do get do - # find specific sample and load only required data - reaction = Reaction.select(:id, :name, :log_data, :updated_at).find(params[:id]) - - analyses = ( - reaction.analyses + - reaction.samples.includes(:container).pluck('containers.id').flat_map { |container_id| Container.analyses_for_root(container_id) } - ).flat_map { |analysis| analysis.self_and_descendants.select(:id, :name, :updated_at, :log_data) } - - # create cache key for reaction - timestamp = [ - reaction.updated_at, - reaction.samples.with_deleted.maximum(:updated_at), - reaction.reactions_samples.with_deleted.maximum(:updated_at), - analyses.map(&:updated_at).max, - Attachment.where(attachable_id: analyses.map(&:id), attachable_type: 'Container').maximum(:updated_at) - ].reject(&:nil?).max.to_i - cache_key = "versions/reactions/#{reaction.id}/#{timestamp}" - - # cache processed and sorted versions of all reaction dependent records and merge them into one list to speed up pagination - versions = Rails.cache.fetch cache_key do - all_versions = reaction.versions_hash - - analyses.each do |analysis| - all_versions += analysis.versions_hash - all_versions += analysis.attachments.select(:attachable_id, :attachable_type, :filename, :log_data).flat_map do |attachment| - attachment.versions_hash(attachment.filename) - end - end - - samples = reaction.samples.with_deleted.select('samples.id, samples.name, samples.log_data') - samples.each do |sample| - all_versions += sample.versions_hash - all_versions += sample.residues.select(:sample_id, :log_data).flat_map do |residue| - residue.versions_hash(sample.name) - end - all_versions += sample.elemental_compositions.select(:sample_id, :log_data).flat_map do |elemental_composition| - elemental_composition.versions_hash(sample.name) - end - end - - reactions_samples = reaction.reactions_samples.with_deleted.select(:sample_id, :log_data, :type) - all_versions += reactions_samples.flat_map do |reactions_sample| - sample = samples.detect { |s| s.id == reactions_sample.sample_id } - reactions_sample.versions_hash(sample.name) - end - - all_versions.sort_by! { |version| -version['t'].to_i } # sort versions with the latest changes in the first place - .each_with_index { |record, index| record['v'] = all_versions.length - index } # adjust v to be uniq and in right order - end + reaction = Reaction.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(reaction) { versions: paginate(Kaminari.paginate_array(versions)) } end end end + + resource :research_plans do + desc 'Return versions of the given research plan' + + params do + requires :id, type: Integer, desc: 'Research plan id' + end + + paginate per_page: 10, offset: 0, max_per_page: 100 + + route_param :id do + get do + research_plan = ResearchPlan.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(research_plan) + + { versions: paginate(Kaminari.paginate_array(versions)) } + end + end + end + + resource :screens do + desc 'Return versions of the given screen' + + params do + requires :id, type: Integer, desc: 'Screen id' + end + + paginate per_page: 10, offset: 0, max_per_page: 100 + + route_param :id do + get do + screen = Screen.with_log_data.find(params[:id]) + versions = Versioning::Fetcher.call(screen) + + { versions: paginate(Kaminari.paginate_array(versions)) } + end + end + end + + resource :revert do + desc 'Revert selected changes' + + params do + requires :changes, type: JSON, desc: 'Changes hash' + end + + post do + Versioning::Reverter.call(params[:changes]) + end + end end end end diff --git a/app/assets/stylesheets/components/svg_with_popver.scss b/app/assets/stylesheets/components/svg_with_popver.scss new file mode 100644 index 0000000000..176f1638ca --- /dev/null +++ b/app/assets/stylesheets/components/svg_with_popver.scss @@ -0,0 +1,3 @@ +.image-with-full-width { + width: 100%; +} \ No newline at end of file diff --git a/app/assets/stylesheets/history.scss b/app/assets/stylesheets/history.scss new file mode 100644 index 0000000000..4d8461f1de --- /dev/null +++ b/app/assets/stylesheets/history.scss @@ -0,0 +1,126 @@ +.history-legend { + display: flex; + justify-content: flex-end; + list-style: none; + padding: 0; + margin: 2px 2px 10px 0; + + &__item { + font-size: 10px; + padding-left: .5em; + + &::before { + content: "\f0c8"; + font-family: FontAwesome; + display: inline-block; + padding: 0 0.2em; + } + + &--old { + &::before { + color: #f2dede; + } + } + + &--new { + &::before { + color: #dff0d8; + } + } + + &--current { + &::before { + color: #f5f5f5; + } + } + } +} + +.history-modal .modal-dialog { + top: 0; + transform: translate(-50%, 0) !important; +} + +.history-alert { + margin-bottom: 0; + margin-top: 20px; +} + +.history-checkbox-label { + margin-bottom: 0; + display: flex; + gap: 5px; + cursor: pointer; + + input { + margin-top: 0 !important; + } +} + +.history-table { + &__caret { + transition: transform .1s ease-out; + } + + &__row { + cursor: pointer; + + &.active { + .history-table__caret { + transform: rotate(90deg); + } + } + } + + &-breadcrumb { + font-size: 13px; + padding: 8px 15px; + margin: 0; + list-style: none; + background-color: #f5f5f5; + border-radius: 4px; + + &:not(:first-child) { + margin-top: 10px; + } + + &__element { + display: inline-block; + } + + &__element + &__element:before { + padding: 0 5px; + color: #ccc; + content: "/ "; + } + } + + .bg-current { + background-color: #f5f5f5; + } + + .row-change { + display: flex; + flex-wrap: wrap; + font-size: 10px; + margin: 10px 0 0; + word-break: break-all; + + & > [class*="col-"] { + padding: 6px; + } + + .ql-container { + font-size: 10px; + } + + .ql-editor { + padding: 0; + } + + .ql-tooltip.ql-hidden { + height: 0; + padding-top: 10px; + } + } +} diff --git a/app/assets/stylesheets/version.scss b/app/assets/stylesheets/version.scss deleted file mode 100644 index 65cef6b42d..0000000000 --- a/app/assets/stylesheets/version.scss +++ /dev/null @@ -1,24 +0,0 @@ -.row.row-version-history { // overwrite bootstrap - display: flex; - flex-wrap: wrap; - font-size: 10px; - margin: 0; - word-break: break-all; - - & + .row-version-history { - margin-top: 15px; - } - - & > [class*="col-"] { - padding: 10px; - } - - .ql-editor { - padding: 0; - } - - .ql-tooltip.ql-hidden { - height: 0; - padding-top: 10px; - } -} \ No newline at end of file diff --git a/app/models/attachment.rb b/app/models/attachment.rb index 3d3cd463dd..ca98802681 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -33,8 +33,7 @@ # class Attachment < ApplicationRecord # rubocop:disable Metrics/ClassLength - include Versionable - + has_logidze include AttachmentJcampAasm include AttachmentJcampProcess include Labimotion::AttachmentConverter diff --git a/app/models/concerns/versionable.rb b/app/models/concerns/versionable.rb deleted file mode 100644 index 7c977fbd77..0000000000 --- a/app/models/concerns/versionable.rb +++ /dev/null @@ -1,215 +0,0 @@ -# frozen_string_literal: true - -# Versionable module -module Versionable - extend ActiveSupport::Concern - - BLACKLISTED_ATTRIBUTES = %w[ - id - created_at - updated_at - parent_id - parent_type - container_type - attachable_id - attachable_type - sample_id - reaction_id - molecule_id - molecule_name_id - type - ].freeze - - included do - has_logidze - end - - def versions_hash(record_name = name) - return [] if log_data.nil? - - result = [] # result data - base = {} # track current version data - log_data.versions.each do |version| - changes = version.changes # get changes for current version - changes_comparison_hash = {} # hash for changes comparison - changes.each do |key, value| - next if key.in?(BLACKLISTED_ATTRIBUTES) # ignore uneeded keys - next if value == base[key] # ignore if value is same as in last version - next if base[key].blank? && value.blank? # ignore if value is empty or nil - - # parse value if needed - old_value = version_value(key, base[key]) - new_value = version_value(key, value) - - if (old_value.is_a?(Hash) || new_value.is_a?(Hash)) && key != 'temperature' - # fix nil cases - old_value ||= {} - new_value ||= {} - base = old_value.merge(new_value) # hash with contains all keys - label = version_label(key, base) # labels for hash - kind = version_kind(key, base) # kinds of hash (numrange, date, string) - - base.each_key do |key| - next if old_value[key] == new_value[key] # ignore if value is same as in last version - next if old_value[key].blank? && new_value[key].blank? # ignore if value is empty or nil - - changes_comparison_hash[key] = { - o: old_value[key], - n: new_value[key], - l: label[key], - k: kind[key] - } - end - else - changes_comparison_hash[key] = { - o: old_value, - n: new_value, - l: version_label(key), # label for attribute, - k: version_kind(key) # kind of attribute (numrange, date, string) - } - end - end - base.merge!(changes) # merge changes with last version data for next iteration - next if changes_comparison_hash.empty? - - result << { - 'k' => version_entity, # record kind (sampe, reaction, ...) - 'n' => record_name, # record name (uses as default the name attribute but in case the model doesn't have a name field or you want to change it) - 't' => Time.at(version.data['ts'] / 1000), # timestamp of the change - 'u' => version_user_names_lookup[version.data.dig('m', '_r')], # user - 'c' => changes_comparison_hash # changes hash - } - end - - result - end - - private - - def version_user_names_lookup - @version_user_names_lookup ||= begin - ids = {} - - log_data.versions.each do |v| - ids[v.data.dig('m', '_r')] ||= 1 - ids[v.changes['created_by']] ||= 1 if v.changes.key?('created_by') - ids[v.changes['created_for']] ||= 1 if v.changes.key?('created_for') - end - - User.with_deleted.where(id: ids.keys).map { |u| [u.id, u.name] }.to_h - end - end - - def version_value(attribute, value) - return if value.nil? - - if self.class.name == 'Reaction' && attribute.in?(%w[description observation]) - YAML.load(value).to_json - elsif attribute.in?(%w[created_by created_for]) - version_user_names_lookup[value] - elsif self.class.name == 'Attachment' && attribute == 'aasm_state' - value.humanize - elsif self.class.name == 'ElementalComposition' && attribute == 'composition_type' - ElementalComposition::TYPES[value.to_sym] - elsif self.class.name == 'Sample' && attribute.in?(%w[boiling_point melting_point]) - value - else - @attributes[attribute].type.deserialize(value) - end - end - - def version_label(attribute, value_hash = {}) - case attribute - when 'timestamp_start' - 'Start' - when 'timestamp_stop' - 'Stop' - when 'observation' - 'Additional information for publication and purification details' - when 'temperature' - 'Temperature' - when 'solvent' - 'Solvent' - else - if self.class.columns_hash[attribute].type.in?(%i[hstore jsonb]) - label_hash = {} - - value_hash.each_key do |key| - value = if self.class.name == 'Container' && key == 'report' - 'Add to Report' - elsif self.class.name == 'Sample' && attribute == 'stereo' - "#{attribute} #{key}".humanize - else - key.underscore.humanize - end - - label_hash.merge!(key => value) - end - - label_hash - else - attribute.underscore.humanize - end - end - end - - def version_kind(attribute, value_hash = {}) - if value_hash.present? - kind_hash = {} - - value_hash.each_key do |key| - value = if self.class.name == 'Container' && key == 'content' || self.class.name == 'Reaction' && key == 'ops' - :quill - elsif self.class.name == 'Container' && key == 'kind' - :treeselect - elsif self.class.name == 'Sample' && key == 'sample_svg_file' - :svg - else - :string - end - - kind_hash.merge!(key => value) - end - - kind_hash - elsif self.class.name == 'Reaction' && attribute.in?(%w[description observation]) - :quill - elsif self.class.name == 'Reaction' && attribute == 'rxno' - :treeselect - else - case attribute - when 'created_at', 'updated_at', 'deleted_at' - :date - when 'melting_point', 'boiling_point' - :numrange - when 'solvent' - :solvent - when 'sample_svg_file' - :svg - when 'temperature' - :temperature - else - :string - end - end - end - - def version_entity - case self.class.name - when 'ReactionsStartingMaterialSample' - 'Starting material' - when 'ReactionsReactantSample' - 'Reactant' - when 'ReactionsSolventSample' - 'Solvent' - when 'ReactionsPurificationSolventSample' - 'Purification solvent' - when 'ReactionsProductSample' - 'Product' - when 'Container' - 'Analysis' - else - self.class.name.underscore.humanize - end - end -end \ No newline at end of file diff --git a/app/models/container.rb b/app/models/container.rb index fd48ca722d..82a1e5bbfb 100644 --- a/app/models/container.rb +++ b/app/models/container.rb @@ -23,9 +23,10 @@ # class Container < ApplicationRecord + has_logidze + acts_as_paranoid include ElementCodes include Labimotion::Datasetable - include Versionable belongs_to :containable, polymorphic: true, optional: true has_many :attachments, as: :attachable diff --git a/app/models/container_hierarchy.rb b/app/models/container_hierarchy.rb new file mode 100644 index 0000000000..03bf50ce4d --- /dev/null +++ b/app/models/container_hierarchy.rb @@ -0,0 +1,2 @@ +class ContainerHierarchy < ApplicationRecord +end diff --git a/app/models/elemental_composition.rb b/app/models/elemental_composition.rb index b798a40a4b..3b33efa6ea 100644 --- a/app/models/elemental_composition.rb +++ b/app/models/elemental_composition.rb @@ -16,7 +16,7 @@ # class ElementalComposition < ApplicationRecord - include Versionable + has_logidze belongs_to :sample diff --git a/app/models/reaction.rb b/app/models/reaction.rb index 18fa302be2..7104bc7567 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -48,6 +48,7 @@ # rubocop:disable Metrics/ClassLength class Reaction < ApplicationRecord + has_logidze acts_as_paranoid include ElementUIStateScopes include PgSearch::Model @@ -56,7 +57,6 @@ class Reaction < ApplicationRecord include Taggable include ReactionRinchi include Labimotion::Segmentable - include Versionable serialize :description, Hash serialize :observation, Hash diff --git a/app/models/reactions_sample.rb b/app/models/reactions_sample.rb index d723c5619f..add51064ec 100644 --- a/app/models/reactions_sample.rb +++ b/app/models/reactions_sample.rb @@ -23,8 +23,7 @@ # class ReactionsSample < ApplicationRecord - include Versionable - + has_logidze acts_as_paranoid belongs_to :reaction, optional: true belongs_to :sample, optional: true diff --git a/app/models/research_plan.rb b/app/models/research_plan.rb index 27c5b47a04..03a8d32884 100644 --- a/app/models/research_plan.rb +++ b/app/models/research_plan.rb @@ -12,6 +12,7 @@ # class ResearchPlan < ApplicationRecord + has_logidze acts_as_paranoid include ElementUIStateScopes include Collectable diff --git a/app/models/research_plan_metadata.rb b/app/models/research_plan_metadata.rb index 09b080477c..73ea14e37e 100644 --- a/app/models/research_plan_metadata.rb +++ b/app/models/research_plan_metadata.rb @@ -44,6 +44,7 @@ class ResearchPlanMetadata < ApplicationRecord + has_logidze self.inheritance_column = nil acts_as_paranoid DATA_CITE_PREFIX = ENV['DATA_CITE_PREFIX'] diff --git a/app/models/residue.rb b/app/models/residue.rb index 8ca53b4a53..ab77f37a62 100644 --- a/app/models/residue.rb +++ b/app/models/residue.rb @@ -15,7 +15,7 @@ # class Residue < ApplicationRecord - include Versionable + has_logidze belongs_to :sample, optional: true validate :loading_present diff --git a/app/models/sample.rb b/app/models/sample.rb index d16818f03d..b790deca56 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -59,6 +59,7 @@ class Sample < ApplicationRecord attr_accessor :skip_inventory_label_update + has_logidze acts_as_paranoid include ElementUIStateScopes include PgSearch::Model @@ -68,43 +69,42 @@ class Sample < ApplicationRecord include UnitConvertable include Taggable include Labimotion::Segmentable - include Versionable STEREO_ABS = ['any', 'rac', 'meso', 'delta', 'lambda', '(S)', '(R)', '(Sp)', '(Rp)', '(Sa)', '(Ra)'].freeze - STEREO_REL = ['any', 'syn', 'anti', 'p-geminal', 'p-ortho', 'p-meta', 'p-para', 'cis', 'trans', 'fac', 'mer'].freeze + STEREO_REL = %w[any syn anti p-geminal p-ortho p-meta p-para cis trans fac mer].freeze STEREO_DEF = { 'abs' => 'any', 'rel' => 'any' }.freeze - multisearchable against: [ - :name, :short_label, :external_label, :molecule_sum_formular, - :molecule_iupac_name, :molecule_inchistring, :molecule_inchikey, :molecule_cano_smiles, - :sample_xref_cas + multisearchable against: %i[ + name short_label external_label molecule_sum_formular + molecule_iupac_name molecule_inchistring molecule_inchikey molecule_cano_smiles + sample_xref_cas ] # search scopes for exact matching - pg_search_scope :search_by_sum_formula, against: :sum_formula, associated_against: { - molecule: :sum_formular + pg_search_scope :search_by_sum_formula, against: :sum_formula, associated_against: { + molecule: :sum_formular, } pg_search_scope :search_by_iupac_name, associated_against: { - molecule: :iupac_name + molecule: :iupac_name, } pg_search_scope :search_by_inchistring, associated_against: { - molecule: :inchistring + molecule: :inchistring, } pg_search_scope :search_by_inchikey, associated_against: { - molecule: :inchikey + molecule: :inchikey, } pg_search_scope :search_by_cano_smiles, associated_against: { - molecule: :cano_smiles + molecule: :cano_smiles, } pg_search_scope :search_by_substring, against: %i[ name short_label external_label ], associated_against: { - molecule: %i[sum_formular iupac_name inchistring inchikey cano_smiles] + molecule: %i[sum_formular iupac_name inchistring inchikey cano_smiles], }, using: { trigram: { threshold: 0.0001 } } pg_search_scope :search_by_sample_name, against: :name @@ -113,45 +113,59 @@ class Sample < ApplicationRecord pg_search_scope :search_by_cas, against: { xref: 'cas' } # scopes for suggestions - scope :by_residues_custom_info, ->(info, val) { joins(:residues).where("residues.custom_info -> '#{info}' ILIKE ?", "%#{sanitize_sql_like(val)}%")} + scope :by_residues_custom_info, lambda { |info, val| + joins(:residues).where("residues.custom_info -> '#{info}' ILIKE ?", "%#{sanitize_sql_like(val)}%") + } scope :by_name, ->(query) { where('name ILIKE ?', "%#{sanitize_sql_like(query)}%") } scope :by_sample_xref_cas, ->(query) { where("xref ? 'cas'").where("xref ->> 'cas' ILIKE ?", "%#{sanitize_sql_like(query)}%") } - scope :by_exact_name, ->(query) { where('lower(name) ~* lower(?) or lower(external_label) ~* lower(?)', "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$", "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$") } + scope :by_exact_name, lambda { |query| + where('lower(name) ~* lower(?) or lower(external_label) ~* lower(?)', "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$", "^([a-zA-Z0-9]+-)?#{sanitize_sql_like(query)}(-?[a-zA-Z])$") + } scope :by_short_label, ->(query) { where('short_label ILIKE ?', "%#{sanitize_sql_like(query)}%") } scope :by_external_label, ->(query) { where('external_label ILIKE ?', "%#{sanitize_sql_like(query)}%") } - scope :by_molecule_sum_formular, ->(query) { + scope :by_molecule_sum_formular, lambda { |query| decoupled_collection = where(decoupled: true).where('sum_formula ILIKE ?', "%#{sanitize_sql_like(query)}%") coupled_collection = where(decoupled: false).joins(:molecule).where('molecules.sum_formular ILIKE ?', "%#{sanitize_sql_like(query)}%") where(id: decoupled_collection + coupled_collection) } - scope :with_reactions, -> { + scope :with_reactions, lambda { joins(:reactions_samples) } - scope :with_wellplates, -> { + scope :with_wellplates, lambda { joins(:well) } scope :by_wellplate_ids, ->(ids) { joins(:wellplates).where('wellplates.id in (?)', ids) } - scope :by_reaction_reactant_ids, ->(ids) { joins(:reactions_reactant_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_product_ids, ->(ids) { joins(:reactions_product_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_material_ids, ->(ids) { joins(:reactions_starting_material_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_solvent_ids, ->(ids) { joins(:reactions_solvent_samples).where('reactions_samples.reaction_id in (?)', ids) } - scope :by_reaction_ids, ->(ids) { joins(:reactions_samples).where('reactions_samples.reaction_id in (?)', ids) } + scope :by_reaction_reactant_ids, lambda { |ids| + joins(:reactions_reactant_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_product_ids, lambda { |ids| + joins(:reactions_product_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_material_ids, lambda { |ids| + joins(:reactions_starting_material_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_solvent_ids, lambda { |ids| + joins(:reactions_solvent_samples).where('reactions_samples.reaction_id in (?)', ids) + } + scope :by_reaction_ids, lambda { |ids| + joins(:reactions_samples).where('reactions_samples.reaction_id in (?)', ids) + } scope :by_literature_ids, ->(ids) { joins(:literals).where(literals: { literature_id: ids }) } scope :includes_for_list_display, -> { includes(:molecule_name, :tag, :comments, molecule: :tag) } scope :product_only, -> { joins(:reactions_samples).where("reactions_samples.type = 'ReactionsProductSample'") } - scope :sample_or_startmat_or_products, -> { - joins("left join reactions_samples rs on rs.sample_id = samples.id").where("rs.id isnull or rs.\"type\" in ('ReactionsProductSample', 'ReactionsStartingMaterialSample')") + scope :sample_or_startmat_or_products, lambda { + joins('left join reactions_samples rs on rs.sample_id = samples.id').where("rs.id isnull or rs.\"type\" in ('ReactionsProductSample', 'ReactionsStartingMaterialSample')") } - scope :search_by_fingerprint_sim, ->(molfile, threshold = 0.01) { + scope :search_by_fingerprint_sim, lambda { |molfile, threshold = 0.01| joins(:fingerprint).merge( - Fingerprint.search_similar(nil, threshold, false, molfile) + Fingerprint.search_similar(nil, threshold, false, molfile), ).order('tanimoto desc') } - scope :search_by_fingerprint_sub, ->(molfile, as_array = false) { + scope :search_by_fingerprint_sub, lambda { |molfile, as_array = false| fp_vector = Chemotion::OpenBabelService.bin_fingerprint_from_molfile(molfile) smarts_query = Chemotion::OpenBabelService.get_smiles_from_molfile(molfile) samples = joins(:fingerprint).merge(Fingerprint.screen_sub(fp_vector)) @@ -163,7 +177,6 @@ class Sample < ApplicationRecord Sample.where(id: samples.map(&:id)) } - before_save :auto_set_molfile_to_molecules_molfile before_save :find_or_create_molecule_based_on_inchikey before_save :update_molecule_name @@ -174,8 +187,8 @@ class Sample < ApplicationRecord before_save :auto_set_short_label before_create :check_molecule_name before_create :set_boiling_melting_points - after_save :update_counter after_create :create_root_container + after_save :update_counter after_save :update_equivalent_for_reactions after_save :update_gas_material after_save :update_svg_for_reactions, unless: :skip_reaction_svg_update? @@ -230,7 +243,8 @@ class Sample < ApplicationRecord accepts_nested_attributes_for :residues, :elemental_compositions, :container, :tag, allow_destroy: true - validates :purity, :numericality => { :greater_than_or_equal_to => 0.0, :less_than_or_equal_to => 1.0, :allow_nil => true } + validates :purity, + numericality: { greater_than_or_equal_to: 0.0, less_than_or_equal_to: 1.0, allow_nil: true } validate :has_collections validates :creator, presence: true @@ -252,7 +266,7 @@ def molecule_sum_formular end def molecule_iupac_name - self.molecule ? self.molecule.iupac_name : '' + molecule ? molecule.iupac_name : '' end def molecule_molecular_weight @@ -260,7 +274,7 @@ def molecule_molecular_weight end def molecule_inchistring - self.molecule ? self.molecule.inchistring : '' + molecule ? molecule.inchistring : '' end def sample_xref_cas @@ -268,19 +282,19 @@ def sample_xref_cas end def molecule_inchikey - self.molecule ? self.molecule.inchikey : '' + molecule ? molecule.inchikey : '' end def molecule_cano_smiles - self.molecule ? self.molecule.cano_smiles : '' + molecule ? molecule.cano_smiles : '' end def analyses - self.container ? self.container.analyses : Container.none + container ? container.analyses : Container.none end def self.associated_by_user_id_and_reaction_ids(user_id, reaction_ids) - (for_user(user_id).by_reaction_ids(reaction_ids)).distinct + for_user(user_id).by_reaction_ids(reaction_ids).distinct end def self.associated_by_user_id_and_wellplate_ids(user_id, wellplate_ids) @@ -351,31 +365,32 @@ def create_chemical_entry_for_subsample(sample_id, subsample_id, type) # rubocop:disable Metrics/PerceivedComplexity # rubocop:disable Style/MethodDefParentheses # rubocop:disable Style/OptionalBooleanParameter - # rubocop:disable Layout/TrailingWhitespace def create_subsample user, collection_ids, copy_ea = false, type = nil - subsample = self.dup + subsample = dup subsample.xref['inventory_label'] = nil subsample.skip_inventory_label_update = true - subsample.name = self.name if self.name.present? - subsample.external_label = self.external_label if self.external_label.present? + subsample.name = name if name.present? + subsample.external_label = external_label if external_label.present? # Ex(p|t)ensive method to get a proper counter: # take into consideration sample children that have been hard/soft deleted - children_count = self.children.with_deleted.count - last_child_label = self.children.with_deleted.order('created_at') - .where('short_label LIKE ?', "#{self.short_label}-%").last&.short_label - last_child_counter = last_child_label && - last_child_label.match(/^#{self.short_label}-(\d+)/) && $1.to_i || 0 + children_count = children.with_deleted.count + last_child_label = children.with_deleted.order('created_at') + .where('short_label LIKE ?', "#{short_label}-%").last&.short_label + last_child_counter = (last_child_label && + last_child_label.match(/^#{short_label}-(\d+)/) && ::Regexp.last_match(1).to_i) || 0 counter = [last_child_counter, children_count].max - subsample.short_label = "#{self.short_label}-#{counter + 1}" + subsample.short_label = "#{short_label}-#{counter + 1}" subsample.parent = self subsample.created_by = user.id - subsample.residues_attributes = self.residues.select(:custom_info, :residue_type).as_json - subsample.elemental_compositions_attributes = self.elemental_compositions.select( - :composition_type, :data, :loading - ).as_json if copy_ea + subsample.residues_attributes = residues.select(:custom_info, :residue_type).as_json + if copy_ea + subsample.elemental_compositions_attributes = elemental_compositions.select( + :composition_type, :data, :loading + ).as_json + end # associate to arg collections and creator's All collection collections = ( @@ -389,19 +404,18 @@ def create_subsample user, collection_ids, copy_ea = false, type = nil create_chemical_entry_for_subsample(id, subsample.id, type) unless type.nil? subsample end + # rubocop:enable Metrics/AbcSize # rubocop:enable Metrics/CyclomaticComplexity # rubocop:enable Metrics/PerceivedComplexity # rubocop:enable Style/MethodDefParentheses # rubocop:enable Style/OptionalBooleanParameter - # rubocop:enable Layout/TrailingWhitespace - def reaction_description reactions.first.try(:description) end def auto_set_molfile_to_molecules_molfile - self.molfile = self.molfile.presence || molecule&.molfile + self.molfile = molfile.presence || molecule&.molfile end def validate_stereo(_stereo = {}) @@ -423,23 +437,22 @@ def find_or_create_molecule_based_on_inchikey is_partial = babel_info[:is_partial] molfile_version = babel_info[:version] - if molecule&.inchikey != inchikey || molecule.is_partial != is_partial - self.molecule = Molecule.find_or_create_by_molfile(molfile, babel_info) - end + return unless molecule&.inchikey != inchikey || molecule.is_partial != is_partial + + self.molecule = Molecule.find_or_create_by_molfile(molfile, babel_info) end def find_or_create_fingerprint return unless molecule_id_changed? || molfile_changed? || fingerprint_id.nil? + self.fingerprint_id = Fingerprint.find_or_create_by_molfile(molfile.clone)&.id end def get_svg_path - if self.sample_svg_file.present? - "/images/samples/#{self.sample_svg_file}" - elsif self.molecule&.molecule_svg_file&.present? - "/images/molecules/#{self.molecule.molecule_svg_file}" - else - nil + if sample_svg_file.present? + "/images/samples/#{sample_svg_file}" + elsif molecule&.molecule_svg_file&.present? + "/images/molecules/#{molecule.molecule_svg_file}" end end @@ -455,7 +468,7 @@ def svg_text_path end def loading - self.residues[0] && self.residues[0].custom_info['loading'].to_f + residues[0] && residues[0].custom_info['loading'].to_f end def attach_svg(svg = sample_svg_file) @@ -463,27 +476,27 @@ def attach_svg(svg = sample_svg_file) svg_file_name = "#{SecureRandom.hex(64)}.svg" - if svg =~ /\ATMPFILE[0-9a-f]{64}.svg\z/ + if /\ATMPFILE[0-9a-f]{64}.svg\z/.match?(svg) src = full_svg_path(svg.to_s) return unless File.file?(src) svg = File.read(src) FileUtils.remove(src) end - if svg.start_with?(/\s*\<\?xml/, /\s*\ 20 + if (tag = preferred_label) && tag && tag.length > 20 tag[0, 20] + '...' else tag @@ -601,35 +612,35 @@ def update_inventory_label(inventory_label, collection_id = nil) private def has_collections - if self.collections_samples.blank? - errors.add(:base, 'must have least one collection') - end + return if collections_samples.present? + + errors.add(:base, 'must have least one collection') end - def set_elem_composition_data d_type, d_values, loading = nil + def set_elem_composition_data(d_type, d_values, loading = nil) attrs = { composition_type: d_type, data: d_values, - loading: loading + loading: loading, } - if item = self.elemental_compositions.find{|i| i.composition_type == d_type} + if item = elemental_compositions.find { |i| i.composition_type == d_type } item.assign_attributes attrs else - self.elemental_compositions << ElementalComposition.new(attrs) + elemental_compositions << ElementalComposition.new(attrs) end end def check_molfile_polymer_section return if decoupled - return unless self.molfile.include? 'R#' + return unless molfile.include? 'R#' - lines = self.molfile.lines + lines = molfile.lines polymers = [] m_end_index = nil lines[4..-1].each_with_index do |line, index| polymers << index if line.include? 'R#' - (m_end_index = index) && break if line.match /M\s+END/ + (m_end_index = index) && break if /M\s+END/.match?(line) end reg = /(> [\W\w.\n]+[\d]+)/m @@ -647,10 +658,10 @@ def check_molfile_polymer_section end def set_loading_from_ea - return unless residue = self.residues.first + return unless residue = residues.first # select from cached attributes, don't make a SQL query - return unless el_composition = self.elemental_compositions.find do |i| + return unless el_composition = elemental_compositions.find do |i| i.composition_type == 'found' end @@ -661,7 +672,9 @@ def update_equivalent_for_reactions rel_reaction_id = reactions_samples.first&.reaction_id return unless rel_reaction_id - ReactionsSample.where(reaction_id: rel_reaction_id, type: %w[ReactionsProductSample ReactionsReactantSample ReactionsStartingMaterialSample]).each(&:update_equivalent) + ReactionsSample.where(reaction_id: rel_reaction_id, + type: %w[ReactionsProductSample ReactionsReactantSample + ReactionsStartingMaterialSample]).each(&:update_equivalent) end def update_svg_for_reactions @@ -674,38 +687,38 @@ def update_svg_for_reactions def auto_set_short_label sh_label = self['short_label'] - return if sh_label =~ /solvents?|reactants?/ + return if /solvents?|reactants?/.match?(sh_label) return if short_label && !short_label_changed? if sh_label && (Sample.find_by(short_label: sh_label) || sh_label.eql?('NEW SAMPLE')) if parent && !((parent_label = parent.short_label) =~ /solvents?|reactants?/) self.short_label = "#{parent_label}-#{parent.children.count.to_i.succ}" elsif creator && creator.counters['samples'] - abbr = self.creator.name_abbreviation - self.short_label = "#{abbr}-#{self.creator.counters['samples'].to_i.succ}" + abbr = creator.name_abbreviation + self.short_label = "#{abbr}-#{creator.counters['samples'].to_i.succ}" end elsif !sh_label && creator && creator.counters['samples'] - abbr = self.creator.name_abbreviation - self.short_label = "#{abbr}-#{self.creator.counters['samples'].to_i.succ}" + abbr = creator.name_abbreviation + self.short_label = "#{abbr}-#{creator.counters['samples'].to_i.succ}" end end - # rubocop: enable Metrics/AbcSize # rubocop: enable Metrics/CyclomaticComplexity # rubocop: enable Metrics/PerceivedComplexity def update_counter - return if short_label =~ /solvents?|reactants?/ || self.parent + return if short_label =~ /solvents?|reactants?/ || parent return unless saved_change_to_short_label? - return unless short_label =~ /^#{self.creator.name_abbreviation}-\d+$/ - self.creator.increment_counter 'samples' + return unless /^#{creator.name_abbreviation}-\d+$/.match?(short_label) + + creator.increment_counter 'samples' end def create_root_container - if self.container == nil - self.container = Container.create_root_container - end + return unless container.nil? + + self.container = Container.create_root_container end def assign_molecule_name @@ -721,7 +734,8 @@ def assign_molecule_name end def check_molecule_name - return unless molecule_name_id.blank? + return if molecule_name_id.present? + assign_molecule_name end @@ -732,6 +746,7 @@ def set_boiling_melting_points def update_molecule_name return unless molecule_id_changed? && molecule_name&.molecule_id != molecule_id + assign_molecule_name end diff --git a/app/models/screen.rb b/app/models/screen.rb index 8447ac74b8..e1d1f037ec 100644 --- a/app/models/screen.rb +++ b/app/models/screen.rb @@ -21,6 +21,7 @@ # class Screen < ApplicationRecord + has_logidze acts_as_paranoid include ElementUIStateScopes include PgSearch::Model diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTable.js b/app/packs/src/apps/mydb/elements/details/VersionsTable.js index ea90ddefcb..35267358c9 100644 --- a/app/packs/src/apps/mydb/elements/details/VersionsTable.js +++ b/app/packs/src/apps/mydb/elements/details/VersionsTable.js @@ -1,4 +1,3 @@ -/* eslint-disable react/forbid-prop-types */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Pager } from 'react-bootstrap'; @@ -6,6 +5,8 @@ import BootstrapTable from 'react-bootstrap-table-next'; import VersionsFetcher from 'src/fetchers/VersionsFetcher'; import VersionsTableTime from 'src/apps/mydb/elements/details/VersionsTableTime'; import VersionsTableChanges from 'src/apps/mydb/elements/details/VersionsTableChanges'; +import { elementShowOrNew } from 'src/utilities/routesUtils'; +import DetailActions from 'src/stores/alt/actions/DetailActions'; export default class VersionsTable extends Component { constructor(props) { @@ -16,8 +17,6 @@ export default class VersionsTable extends Component { page: 1, pages: 1, }; - - this.updateParent = this.updateParent.bind(this); } componentDidMount() { @@ -36,11 +35,29 @@ export default class VersionsTable extends Component { } }; + reloadEntity = () => { + const { id, type, element } = this.props; + const entityType = type.slice(0, -1); + + if (entityType === 'reaction') { + DetailActions.close(element, true); + } + + elementShowOrNew({ + type: entityType, + params: { [`${entityType}ID`]: id } + }); + }; + + handleRevert = (changes) => VersionsFetcher.revert(changes) + .then(() => this.fetchVersions()) + .then(() => this.reloadEntity()); + fetchVersions() { const { type, id } = this.props; const { page } = this.state; - VersionsFetcher.fetch({ + return VersionsFetcher.fetch({ type, id, page }).then((result) => { if (!result) return false; @@ -53,13 +70,8 @@ export default class VersionsTable extends Component { }); } - updateParent(name, kind, value) { - this.props.updateGrandparent(name, kind, value); - } - render() { const { versions, page, pages } = this.state; - const { type } = this.props; const pagination = () => ( @@ -69,7 +81,7 @@ export default class VersionsTable extends Component { onClick={() => this.handlePagerClick('prev')} disabled={page >= pages} > - ← Previous Page + ← Older Versions this.handlePagerClick('next')} disabled={page <= 1} > - Next Page → + Newer Versions → ); const columns = [ + { + dataField: 'caret', + text: '', + isDummyField: true, + // eslint-disable-next-line react/no-unstable-nested-components + formatter: () => , + }, { dataField: 'id', text: '#', }, { dataField: 'createdAt', - text: 'Created', + text: 'Modified on', // eslint-disable-next-line react/no-unstable-nested-components formatter: (cell) => ( ), }, - { - dataField: 'klass', - text: 'Entity', - }, - { - dataField: 'name', - text: 'Name', - }, { dataField: 'userName', text: 'Author', @@ -112,8 +123,12 @@ export default class VersionsTable extends Component { const expandRow = { onlyOneExpanding: true, parentClassName: 'active', - renderer: row => ( - + renderer: (row) => ( + ), }; @@ -121,18 +136,24 @@ export default class VersionsTable extends Component { <> +
    +
  • before
  • +
  • after
  • +
  • current value
  • +
{pagination()} @@ -143,4 +164,9 @@ export default class VersionsTable extends Component { VersionsTable.propTypes = { type: PropTypes.string.isRequired, id: PropTypes.number.isRequired, + element: PropTypes.object, +}; + +VersionsTable.defaultProps = { + element: {} }; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js b/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js index 6ffd59cf24..0a2f0f24e9 100644 --- a/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableChanges.js @@ -1,204 +1,52 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Row, Col, FormControl, Button, Table } from 'react-bootstrap'; -import moment from 'moment'; -import QuillViewer from 'src/components/QuillViewer'; -import SVG from 'react-inlinesvg'; -import ReactJson from 'react-json-view'; -import EditableCell from './lineChart/EditableCell' - -const SolventDetails = ({ solvent }) => { - if (!solvent) { - return (<>) - } - - return ( - - - - - - - - - - - - ) -}; +import VersionsTableFields from 'src/apps/mydb/elements/details/VersionsTableFields'; +import VersionsTableModal from 'src/apps/mydb/elements/details/VersionsTableModal'; +import { Alert } from 'react-bootstrap'; function VersionsTableChanges(props) { - const { changes } = props; - - const date = (input) => ( - input ? moment(input).format('YYYY-MM-DD HH:mm') : '' - ); + const { + id, changes, handleRevert + } = props; - const quill = (input) => ( - input ? : '' - ); + const revertable = () => { + let filteredFields = []; - const numrange = input => ( - input ? `${input.slice(1, -1).split(',')[0]} - ${input.slice(1, -1).split(',')[1]}`: '' - ); - - const treeselect = (input) => ( - (input || '').split(' | ', 2)[1] || input - ); + changes.forEach(({ fields }) => { + filteredFields = filteredFields.concat( + fields.filter((field) => (field.currentValue !== field.oldValue && field.revert.length > 0)) + ); + }); - const svg = input => ( - input ? : '' - ); - - const solvent = input => { - let contents = [] - if (input) { - input.forEach((solv) => { - contents.push(( - - )) - }) - } - - return input ? (
- - - - - - - - - - {contents.map(item => item)} - -
LabelRatio -
-
) : <>; + return filteredFields.length > 0; }; - const temperature = input => { - if (input) { - var rows = [] - var data = input.data; - for (let i = 0; i < data.length; i = i + 1) { - let row = ( - - - - - -
-
- -
-
- - - ) - rows.push(row) - } - - return input ? (
- Temperature: {input.userText} {input.valueUnit} - - - - - - - - - {rows} - -
Time (hh:mm:ss) Temperature ({input.valueUnit})
-
) : <>; - } - } - - const handleRevert = (name, kind, value) => { - props.updateParent(name, kind, value); - } - - const renderRevertButton = (name, kind, oldValue) => { - if (['location', 'name', 'external_label', 'real_amount_value', 'description', 'solvent', - 'real_amount_unit', 'showed_name', 'target_amount_unit', 'target_amount_value', 'boiling_point', - 'melting_point', 'short_label', 'purity', 'density', 'molarity_value', 'data', 'temperature'].includes(name)) { - return (); - } - } - - const formatValue = (kind, value) => { - const formatters = { - date, - quill, - numrange, - treeselect, - svg, - solvent, - temperature, - string: () => JSON.stringify(value), - }; - - return ( - formatters[kind] || formatters.string - )(value); - }; + const change = changes.map(({ name, fields }, index) => ( + // eslint-disable-next-line react/no-array-index-key + +
    + {name.map((item) => ( +
  1. + {item} +
  2. + ))} +
+ +
+ )); return ( <> - { - changes.map(({ - name, label, kind, oldValue, newValue - }) => ( -
- - - {label} - {renderRevertButton(name, kind, oldValue)} - - - - - {formatValue(kind, oldValue)} - - - {formatValue(kind, newValue)} - - -
- )) - } + {change} + {revertable() ? : You cannot undo these changes. Either the changes are up to date, it is the first version or all changes are irreversible.} ); } VersionsTableChanges.propTypes = { + id: PropTypes.number.isRequired, changes: PropTypes.arrayOf(PropTypes.object).isRequired, + handleRevert: PropTypes.func.isRequired, }; export default VersionsTableChanges; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableFields.js b/app/packs/src/apps/mydb/elements/details/VersionsTableFields.js new file mode 100644 index 0000000000..5496e61e96 --- /dev/null +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableFields.js @@ -0,0 +1,352 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Row, Col, FormControl, Table +} from 'react-bootstrap'; +import moment from 'moment'; +import SvgWithPopover from 'src/components/common/SvgWithPopover'; +import QuillViewer from 'src/components/QuillViewer'; + +function SolventDetails({ solvent }) { + if (!solvent) { + return (null); + } + + return ( + + + + + + + + + + + ); +} + +SolventDetails.propTypes = { + solvent: PropTypes.shape({ + label: PropTypes.string.isRequired, + ratio: PropTypes.string.isRequired, + }).isRequired, +}; + +function VersionsTableFields(props) { + const { + fields, renderRevertView, handleRevertFieldToggle, revertSelectedFields, revertGroupIndex + } = props; + + const date = (input) => ( + input ? moment(input).format('YYYY-MM-DD HH:mm') : '' + ); + + const quill = (input) => ( + input ? : '' + ); + + const numrange = (input) => ( + input ? `${input.slice(1, -1).split(',')[0]} - ${input.slice(1, -1).split(',')[1]}` : '' + ); + + const treeselect = (input) => ( + (input || '').split(' | ', 2)[1] || input + ); + + const image = (input, title) => (input ? ( + + ) : ( + '' + )); + + const solvent = (input) => { + const contents = []; + if (input) { + input.forEach((solv, index) => { + contents.push(( + + )); + }); + } + + return input ? ( +
+ + + + + + + + + {contents.map((item) => item)} + +
+ LabelRatio +
+
+ ) : null; + }; + + const temperature = (input) => (input ? ( +
+

+ {input.userText} + {' '} + {input.valueUnit} +

+ {input.data.length > 0 && ( + + + + + + + + + {input.data.map(({ time, value }, index) => ( + // eslint-disable-next-line react/no-array-index-key + + + + + ))} + +
Time (hh:mm:ss) + {' '} + Temperature ( + {input.valueUnit} + ) + {' '} +
{time}{value}
+ )} +
+ ) : ( + null + )); + + const table = (input) => { + const { columns, rows } = input; + + // eslint-disable-next-line react/no-array-index-key + const th = columns.map((column, index) => {column.headerName}); + + const tr = rows.map((row, i) => { + const td = columns.map((column, j) => ( + // eslint-disable-next-line react/no-array-index-key + + {row[column.colId]} + + )); + + return ( + // eslint-disable-next-line react/no-array-index-key + + {td} + + ); + }); + + return ( + + + + {th} + + + + {tr} + +
+ ); + }; + + const string = (input) => ( + input + .toString() + .split('\n') + .map((line, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {line} +
+
+ )) + ); + + const boolean = (input) => ( + + ); + + const json = (input) => { + const array = Array.isArray(input) ? input : [input]; + const formatters = { + quill, + image, + table, + string, + }; + + return array.map((jsonObject, i) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {jsonObject.map((object, j) => { + const { title, content, kind } = object; + + return ( + // eslint-disable-next-line react/no-array-index-key + + {title} +
+
+ {(formatters[kind] || formatters.string)(content, title)} +
+
+ ); + })} +
+ )); + }; + + const formatValue = (kind, value, label) => { + if (value == null) return ''; + + const formatters = { + date, + quill, + numrange, + treeselect, + solvent, + temperature, + string, + image, + boolean, + json, + }; + + return ( + formatters[kind] || formatters.string + )(value, label); + }; + + return ( + <> + { + fields.filter((field) => (field.kind !== 'hidden')).map((field, index) => { + if (renderRevertView) { + const key = `${revertGroupIndex}${index}`; + return ( + // eslint-disable-next-line react/no-array-index-key + + + + + + {formatValue(field.kind, field.currentValue, field.label)} + + + {formatValue(field.kind, field.oldValue, field.label)} + + + ); + } + return ( + // eslint-disable-next-line react/no-array-index-key + + + {field.label} + + + {formatValue(field.kind, field.oldValue, field.label)} + + + {formatValue(field.kind, field.newValue, field.label)} + + + {formatValue(field.kind, field.currentValue, field.label)} + + + ); + }) + } + + ); +} + +VersionsTableFields.defaultProps = { + handleRevertFieldToggle: () => {}, + revertSelectedFields: [], + revertGroupIndex: 0, +}; + +VersionsTableFields.propTypes = { + fields: PropTypes.arrayOf(PropTypes.object).isRequired, + renderRevertView: PropTypes.bool.isRequired, + handleRevertFieldToggle: PropTypes.func, + revertSelectedFields: PropTypes.arrayOf(PropTypes.string), + revertGroupIndex: PropTypes.number, +}; + +export default VersionsTableFields; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableModal.js b/app/packs/src/apps/mydb/elements/details/VersionsTableModal.js new file mode 100644 index 0000000000..bb7d3482d8 --- /dev/null +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableModal.js @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { + Row, Col, Modal, Button, Alert +} from 'react-bootstrap'; +import VersionsTableFields from 'src/apps/mydb/elements/details/VersionsTableFields'; + +function VersionsTableModal(props) { + const { name, changes, handleRevert } = props; + const [show, setshow] = useState(false); + const [selectedFields, setSelectedFields] = useState([]); + const handleClose = () => setshow(false); + const handleShow = () => setshow(true); + + const fieldIndexDictionary = () => { + const dictionary = {}; + + changes.map(({ fields }, groupIndex) => fields + .filter( + (field) => field.kind !== 'hidden' + && field.currentValue !== field.oldValue + && field.revert.length > 0 + ) + .forEach((field, index) => { + dictionary[`${groupIndex}${index}`] = field; + })); + + return dictionary; + }; + + const allFieldIndexes = () => Object.keys(fieldIndexDictionary()); + + const toggleAllFieldsCheckbox = (element) => { + if (element.target.checked) { + setSelectedFields(allFieldIndexes()); + } else { + setSelectedFields([]); + } + }; + + const toggleFieldCheckbox = (element) => { + const selectedField = fieldIndexDictionary()[element]; + const affectedIndexes = []; + changes.forEach(({ fields }, groupIndex) => { + if (fields.includes(selectedField)) { + fields + .filter( + (field) => field.kind !== 'hidden' + && field.currentValue !== field.oldValue + && field.revert.length > 0 + ) + .forEach((field, index) => { + if (selectedField.revert.includes(field.name)) { + affectedIndexes.push(`${groupIndex}${index}`); + } + }); + } + }); + + if (selectedFields.includes(element)) { + setSelectedFields( + selectedFields.filter((index) => !affectedIndexes.includes(index)) + ); + } else { + setSelectedFields(selectedFields.concat(affectedIndexes)); + } + }; + + const change = changes.map((historyChange, index) => { + const filteredFields = historyChange.fields.filter( + (field) => field.currentValue !== field.oldValue && field.revert.length > 0 + ); + + if (filteredFields.length > 0) { + return ( + // eslint-disable-next-line react/no-array-index-key + +
    + {historyChange.name.map((item) => ( +
  1. + {item} +
  2. + ))} +
+ +
+ ); + } + + return ''; + }); + + const reversibleChanges = () => { + const result = []; + const dictionary = fieldIndexDictionary(); + + changes.forEach((historyChange) => { + const affectedChangeFields = []; + + selectedFields.forEach((fieldIndex) => { + const selectedField = dictionary[fieldIndex]; + + if (historyChange.fields.includes(selectedField)) { + historyChange.fields + .filter((f) => selectedField.revert.includes(f.name)) + .forEach((field) => { + if (!affectedChangeFields.includes(field)) { + affectedChangeFields.push(field); + } + }); + } + }); + + if (affectedChangeFields.length > 0) { + result.push({ + db_id: historyChange.db_id, + klass_name: historyChange.klass_name, + fields: affectedChangeFields.map((field) => ({ + value: field.revertibleValue, + name: field.name, + })), + }); + } + }); + + console.log(result); + + return result; + }; + + return ( + <> + + + + {name} + +
+ + + + + +
    +
  • + current value +
  • +
  • + before/new value +
  • +
+ +
+
+ +
{change}
+ + Only reversible changes are are shown. + +
+ + + + +
+
+ + ); +} + +VersionsTableModal.propTypes = { + name: PropTypes.string.isRequired, + changes: PropTypes.arrayOf(PropTypes.object).isRequired, + handleRevert: PropTypes.func.isRequired, +}; + +export default VersionsTableModal; diff --git a/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js b/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js index bee14eaf0d..e0b3b034f9 100644 --- a/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js +++ b/app/packs/src/apps/mydb/elements/details/VersionsTableTime.js @@ -6,9 +6,13 @@ import moment from 'moment'; function VersionsTableTime(props) { const { dateTime } = props; + const formattedTime = () => moment(dateTime).format('YYYY-MM-DD HH:mm:ss'); + + const timeFromNow = () => moment(dateTime).fromNow(); + const renderTooltip = () => ( - {moment(dateTime).format('YYYY-MM-DD HH:mm')} + {timeFromNow()} ); @@ -17,7 +21,7 @@ function VersionsTableTime(props) { placement="top" overlay={renderTooltip(dateTime)} > - {moment(dateTime).fromNow()} + {formattedTime()} ); } diff --git a/app/packs/src/fetchers/VersionsFetcher.js b/app/packs/src/fetchers/VersionsFetcher.js index 8ada77e13d..80e03ba146 100644 --- a/app/packs/src/fetchers/VersionsFetcher.js +++ b/app/packs/src/fetchers/VersionsFetcher.js @@ -1,5 +1,5 @@ import 'whatwg-fetch'; -import Version from 'src/models/Version'; +import HistoryVersion from 'src/models/HistoryVersion'; export default class VersionsFetcher { static fetch({ @@ -15,7 +15,7 @@ export default class VersionsFetcher { credentials: 'same-origin' }).then((response) => ( response.json().then((json) => ({ - elements: json.versions.map((v) => (new Version(v))), + elements: json.versions.map((v) => (new HistoryVersion(v))), totalElements: parseInt(response.headers.get('X-Total'), 10), page: parseInt(response.headers.get('X-Page'), 10), pages: parseInt(response.headers.get('X-Total-Pages'), 10), @@ -23,4 +23,18 @@ export default class VersionsFetcher { })) )).catch((errorMessage) => { console.log(errorMessage); }); } + + static revert(json) { + return fetch('/api/v1/versions/revert', { + credentials: 'same-origin', + method: 'post', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + changes: json + }) + }); + } } diff --git a/app/packs/src/models/Change.js b/app/packs/src/models/Change.js deleted file mode 100644 index ea5ae603ac..0000000000 --- a/app/packs/src/models/Change.js +++ /dev/null @@ -1,11 +0,0 @@ -export default class Change { - constructor([name, { - l, k, o, n, - }]) { - this.name = name; - this.label = l; - this.kind = k; - this.oldValue = o; - this.newValue = n; - } -} diff --git a/app/packs/src/models/HistoryChange.js b/app/packs/src/models/HistoryChange.js new file mode 100644 index 0000000000..97000f5332 --- /dev/null +++ b/app/packs/src/models/HistoryChange.js @@ -0,0 +1,13 @@ +/* eslint-disable camelcase */ +import HistoryField from 'src/models/HistoryField'; + +export default class HistoryChange { + constructor({ + name, fields, db_id, klass_name, + }) { + this.name = name; + this.db_id = db_id; + this.klass_name = klass_name; + this.fields = Object.entries(fields).map((field) => (new HistoryField(field))); + } +} diff --git a/app/packs/src/models/HistoryField.js b/app/packs/src/models/HistoryField.js new file mode 100644 index 0000000000..49df4f7f18 --- /dev/null +++ b/app/packs/src/models/HistoryField.js @@ -0,0 +1,15 @@ +/* eslint-disable camelcase */ +export default class HistoryField { + constructor([name, { + label, kind, old_value, new_value, current_value, revert, revertible_value + }]) { + this.name = name; + this.label = label; + this.kind = kind; + this.oldValue = old_value; + this.newValue = new_value; + this.currentValue = current_value; + this.revert = revert; + this.revertibleValue = revertible_value; + } +} diff --git a/app/packs/src/models/HistoryVersion.js b/app/packs/src/models/HistoryVersion.js new file mode 100644 index 0000000000..6acb4c1491 --- /dev/null +++ b/app/packs/src/models/HistoryVersion.js @@ -0,0 +1,12 @@ +import HistoryChange from 'src/models/HistoryChange'; + +export default class HistoryVersion { + constructor({ + id, time, user, changes, + }) { + this.id = id; + this.createdAt = new Date(time); + this.userName = user; + this.changes = changes.map((change) => (new HistoryChange(change))); + } +} diff --git a/app/packs/src/models/ResearchPlan.js b/app/packs/src/models/ResearchPlan.js index f89034b06a..853f5e4969 100644 --- a/app/packs/src/models/ResearchPlan.js +++ b/app/packs/src/models/ResearchPlan.js @@ -204,6 +204,10 @@ export default class ResearchPlan extends Element { (attachmentInResearchPlan) => attachmentInResearchPlan.identifier ); + if (!attachmentsToAdd) { + return + } + attachmentsToAdd .filter((attachment) => idsOfAttachmentsInResearchPlan.includes(attachment.identifier)) .map((source) => { @@ -233,11 +237,14 @@ export default class ResearchPlan extends Element { removeFieldFromBody(fieldId) { const index = this.body.findIndex((field) => field.id === fieldId); if (index === -1) { return; } - let { identifier } = this.body[index].value; - if (!identifier) { - identifier = this.body[index].value.public_name; + + if (this.body[index].value) { + let { identifier } = this.body[index].value; + if (!identifier) { + identifier = this.body[index].value.public_name; + } + this.markAttachmentAsDeleted(identifier); } - this.markAttachmentAsDeleted(identifier); this.body.splice(index, 1); this.changed = true; } diff --git a/app/packs/src/models/Version.js b/app/packs/src/models/Version.js deleted file mode 100644 index bd2d074ec6..0000000000 --- a/app/packs/src/models/Version.js +++ /dev/null @@ -1,16 +0,0 @@ -import Change from 'src/models/Change'; - -export default class Version { - constructor({ - v, k, n, t, u, c, - }) { - const changes = Object.entries(c).map((change) => (new Change(change))); - - this.id = v; - this.klass = k; - this.name = n; - this.createdAt = new Date(t); - this.userName = u; - this.changes = changes; - } -} diff --git a/app/packs/src/stores/alt/actions/ElementActions.js b/app/packs/src/stores/alt/actions/ElementActions.js index 4541c36d64..82b29346cd 100644 --- a/app/packs/src/stores/alt/actions/ElementActions.js +++ b/app/packs/src/stores/alt/actions/ElementActions.js @@ -369,19 +369,19 @@ class ElementActions { createSample(params, closeView = false) { return (dispatch) => { - SamplesFetcher.create(params) - .then((result) => { - dispatch({ element: result, closeView }) - }); + return SamplesFetcher.create(params) + .then((result) => { + dispatch({ element: result, closeView }) + }); }; } createSampleForReaction(sample, reaction, materialGroup) { return (dispatch) => { - SamplesFetcher.create(sample) - .then((newSample) => { - dispatch({ newSample, reaction, materialGroup }) - }); + return SamplesFetcher.create(sample) + .then((newSample) => { + dispatch({ newSample, reaction, materialGroup }) + }); }; } @@ -425,25 +425,25 @@ class ElementActions { updateSampleForReaction(sample, reaction, closeView = true) { return (dispatch) => { - SamplesFetcher.update(sample) - .then((newSample) => { - reaction.updateMaterial(newSample); - reaction.changed = true; - dispatch({ reaction, sample: newSample, closeView }) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return SamplesFetcher.update(sample) + .then((newSample) => { + reaction.updateMaterial(newSample); + reaction.changed = true; + dispatch({ reaction, sample: newSample, closeView }) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } updateSample(params, closeView = false) { return (dispatch) => { - SamplesFetcher.update(params) - .then((result) => { - dispatch({ element: result, closeView }) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return SamplesFetcher.update(params) + .then((result) => { + dispatch({ element: result, closeView }) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } @@ -604,21 +604,21 @@ class ElementActions { createReaction(params) { return (dispatch) => { - ReactionsFetcher.create(params) - .then((result) => { - dispatch(result) - }); + return ReactionsFetcher.create(params) + .then((result) => { + dispatch(result) + }); }; } updateReaction(params, closeView = false) { return (dispatch) => { - ReactionsFetcher.update(params) - .then((result) => { - dispatch({ element: result, closeView }) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return ReactionsFetcher.update(params) + .then((result) => { + dispatch({ element: result, closeView }) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } @@ -748,12 +748,12 @@ class ElementActions { updateSampleForWellplate(sample, wellplate) { return (dispatch) => { - SamplesFetcher.update(sample) - .then((newSample) => { - dispatch(wellplate) - }).catch((errorMessage) => { - console.log(errorMessage); - }); + return SamplesFetcher.update(sample) + .then((newSample) => { + dispatch(wellplate) + }).catch((errorMessage) => { + console.log(errorMessage); + }); }; } diff --git a/app/services/versioning/fetcher.rb b/app/services/versioning/fetcher.rb new file mode 100644 index 0000000000..4b3a2139c5 --- /dev/null +++ b/app/services/versioning/fetcher.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Versioning::Fetcher + include ActiveModel::Model + + attr_accessor :record + + def self.call(record) + new(record: record).call + end + + def call + Versioning::Merger.call(versions: versions) + end + + private + + def versions + case record + when ::Sample + Versioning::Fetchers::SampleFetcher.call(sample: record) + when ::Reaction + Versioning::Fetchers::ReactionFetcher.call(reaction: record) + when ::ResearchPlan + Versioning::Fetchers::ResearchPlanFetcher.call(research_plan: record) + when ::Screen + Versioning::Fetchers::ScreenFetcher.call(screen: record) + end + end +end diff --git a/app/services/versioning/fetchers/reaction_fetcher.rb b/app/services/versioning/fetchers/reaction_fetcher.rb new file mode 100644 index 0000000000..3e30e090bc --- /dev/null +++ b/app/services/versioning/fetchers/reaction_fetcher.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::ReactionFetcher + include ActiveModel::Model + + attr_accessor :reaction + + def self.call(**args) + new(**args).call + end + + def call + versions = Versioning::Serializers::ReactionSerializer.call(reaction) + + reaction.reactions_samples.with_deleted.with_log_data.each do |reactions_sample| + sample = Sample.with_log_data.with_deleted.find(reactions_sample.sample_id) + + sample_type = label_for_sample_type(reactions_sample.type) + sample_name = (!sample_type.in?(%w[Reactant Solvent]) && sample.short_label).presence || sample.name.presence || sample.external_label.presence || sample.molecule.iupac_name || '-' + + versions += Versioning::Serializers::ReactionsSampleSerializer.call(reactions_sample, ["#{sample_type}: #{sample_name}"]) + versions += Versioning::Serializers::SampleSerializer.call(sample, ["#{sample_type}: #{sample_name}", 'Properties']) + + versions += sample.residues.with_log_data.flat_map do |residue| + Versioning::Serializers::ResidueSerializer.call(residue, ["#{sample_type}: #{sample_name} Polymer section"]) + end + + versions += sample.elemental_compositions.with_log_data.flat_map do |elemental_composition| + Versioning::Serializers::ElementalCompositionSerializer.call(elemental_composition, ["#{sample_type}: #{sample_name}", 'Elemental composition']) + end + + analyses_container = sample.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, ["#{sample_type}: #{sample_name}", "Analysis: #{analysis.name}"]) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, ["#{sample_type}: #{sample_name}", "Analysis: #{analysis.name}", "Dataset: #{dataset.name}"]) + + versions += dataset.attachments.with_log_data.flat_map do |attachment| + Versioning::Serializers::AttachmentSerializer.call(attachment, ["#{sample_type}: #{sample_name}", "Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"]) + end + end + end + end + + versions + end + + private + + def label_for_sample_type(type) + { + ReactionsStartingMaterialSample: 'Starting material', + ReactionsReactantSample: 'Reactant', + ReactionsSolventSample: 'Solvent', + ReactionsPurificationSolventSample: 'Purification solvent', + ReactionsProductSample: 'Product', + }[type.to_sym] + end +end diff --git a/app/services/versioning/fetchers/research_plan_fetcher.rb b/app/services/versioning/fetchers/research_plan_fetcher.rb new file mode 100644 index 0000000000..fea8f7eb8c --- /dev/null +++ b/app/services/versioning/fetchers/research_plan_fetcher.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::ResearchPlanFetcher + include ActiveModel::Model + + attr_accessor :research_plan, :prefix + + def self.call(**args) + new(**args).call + end + + def call + research_plan_name = prefix.present? ? [prefix] : ['Research plan'] + versions = Versioning::Serializers::ResearchPlanSerializer.call(research_plan, research_plan_name) + + research_plan_metadata = research_plan.research_plan_metadata + if research_plan_metadata + research_plan_metadata.reload_log_data + research_plan_metadata_name = prefix.present? ? [prefix, 'Metadata'] : ['Metadata'] + versions += Versioning::Serializers::ResearchPlanMetadataSerializer.call(research_plan_metadata, research_plan_metadata_name) + end + + research_plan.attachments.with_log_data.each do |attachment| + versions += Versioning::Serializers::AttachmentSerializer.call(attachment, [prefix, "Attachment: #{attachment.filename}"].compact) + end + + analyses_container = research_plan.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, [prefix, "Analysis: #{analysis.name}"].compact) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, [prefix, "Analysis: #{analysis.name}", "Dataset: #{dataset.name}"].compact) + + dataset.attachments.with_log_data.each do |attachment| + versions += Versioning::Serializers::AttachmentSerializer.call(attachment, [prefix, "Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"].compact) + end + end + end + + versions + end +end diff --git a/app/services/versioning/fetchers/sample_fetcher.rb b/app/services/versioning/fetchers/sample_fetcher.rb new file mode 100644 index 0000000000..2afba2eef5 --- /dev/null +++ b/app/services/versioning/fetchers/sample_fetcher.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::SampleFetcher + include ActiveModel::Model + + attr_accessor :sample + + def self.call(**args) + new(**args).call + end + + def call + versions = Versioning::Serializers::SampleSerializer.call(sample) + versions += sample.residues.with_log_data.flat_map do |residue| + Versioning::Serializers::ResidueSerializer.call(residue) + end + versions += sample.elemental_compositions.with_log_data.flat_map do |elemental_composition| + Versioning::Serializers::ElementalCompositionSerializer.call(elemental_composition) + end + + analyses_container = sample.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, ["Analysis: #{analysis.name}"]) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}"]) + + versions += dataset.attachments.with_log_data.flat_map do |attachment| + Versioning::Serializers::AttachmentSerializer.call(attachment, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"]) + end + end + end + + versions + end +end diff --git a/app/services/versioning/fetchers/screen_fetcher.rb b/app/services/versioning/fetchers/screen_fetcher.rb new file mode 100644 index 0000000000..c2d657c387 --- /dev/null +++ b/app/services/versioning/fetchers/screen_fetcher.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Versioning::Fetchers::ScreenFetcher + include ActiveModel::Model + + attr_accessor :screen + + def self.call(**args) + new(**args).call + end + + def call + versions = Versioning::Serializers::ScreenSerializer.call(screen) + + analyses_container = screen.container.children.where(container_type: :analyses).first + analyses_container.children.where(container_type: :analysis).with_deleted.with_log_data.each do |analysis| + versions += Versioning::Serializers::ContainerSerializer.call(analysis, ["Analysis: #{analysis.name}"]) + + analysis.children.with_deleted.with_log_data.each do |dataset| + versions += Versioning::Serializers::ContainerSerializer.call(dataset, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}"]) + + dataset.attachments.with_log_data.each do |attachment| + versions += Versioning::Serializers::AttachmentSerializer.call(attachment, ["Analysis: #{analysis.name}", "Dataset: #{dataset.name}", "Attachment: #{attachment.filename}"]) + end + end + end + + screen.research_plans.each do |research_plan| + versions += Versioning::Fetchers::ResearchPlanFetcher.call(research_plan: research_plan, prefix: "Research Plan: #{research_plan.name}") + end + + versions + end +end diff --git a/app/services/versioning/merger.rb b/app/services/versioning/merger.rb new file mode 100644 index 0000000000..5bed545791 --- /dev/null +++ b/app/services/versioning/merger.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Versioning::Merger + include ActiveModel::Model + + attr_accessor :versions + + def self.call(**args) + new(**args).call + end + + def call + # sort versions with the latest changes in the first place + versions.sort_by! { |version| -version[:time].to_i } + + grouped_versions = versions.group_by { |version| version[:uuid] } + + merged_versions = [] + grouped_versions.each_with_index do |(_, v), index| + changes = v.map do |v| + { + db_id: v[:db_id], + klass_name: v[:klass_name], + name: v[:name], + fields: v[:changes], + } + end + + merged_versions << { + id: grouped_versions.length - index, + time: v.dig(0, :time), + user: v.dig(0, :user), + changes: changes, + } + end + merged_versions + end +end diff --git a/app/services/versioning/reverter.rb b/app/services/versioning/reverter.rb new file mode 100644 index 0000000000..a08734142e --- /dev/null +++ b/app/services/versioning/reverter.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class Versioning::Reverter + include ActiveModel::Model + + attr_accessor :changes + + def self.call(changes) + new(changes: changes).call + end + + def call + changes.each do |change| + case change['klass_name'] + when 'Attachment' + Versioning::Reverters::AttachmentReverter.call(change) + when 'Container' + Versioning::Reverters::ContainerReverter.call(change) + when 'ElementalComposition' + Versioning::Reverters::ElementalCompositionReverter.call(change) + when 'Reaction' + Versioning::Reverters::ReactionReverter.call(change) + when 'ReactionsSample' + Versioning::Reverters::ReactionsSampleReverter.call(change) + when 'ResearchPlan' + Versioning::Reverters::ResearchPlanReverter.call(change) + when 'ResearchPlanMetadata' + Versioning::Reverters::ResearchPlanMetadataReverter.call(change) + when 'ResearchPlan' + Versioning::Reverters::ResearchPlanReverter.call(change) + when 'Residue' + Versioning::Reverters::ResidueReverter.call(change) + when 'Sample' + Versioning::Reverters::SampleReverter.call(change) + when 'Screen' + Versioning::Reverters::ScreenReverter.call(change) + end + end + end +end diff --git a/app/services/versioning/reverters/attachment_reverter.rb b/app/services/versioning/reverters/attachment_reverter.rb new file mode 100644 index 0000000000..66f90bcb5c --- /dev/null +++ b/app/services/versioning/reverters/attachment_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::AttachmentReverter < Versioning::Reverters::BaseReverter + def self.scope + Attachment + end +end diff --git a/app/services/versioning/reverters/base_reverter.rb b/app/services/versioning/reverters/base_reverter.rb new file mode 100644 index 0000000000..79540392ba --- /dev/null +++ b/app/services/versioning/reverters/base_reverter.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Versioning::Reverters::BaseReverter + include ActiveModel::Model + + attr_accessor :record, :fields + + class << self + def call(change) + new( + record: scope.find(change['db_id']), + fields: change['fields'], + ).call + end + end + + def call + attributes = { updated_at: Time.current } + + fields.each do |field| + name = field['name'] + value = field['value'] + + field_definition = field_definitions[name] + + if field_definition + attributes[name] = field_definition.call(value) + elsif name.include?('.') + name, key = name.split('.') + + attributes[name] ||= record[name] + attributes[name][key] = value + else + attributes[name] = value + end + end + record.update_columns(attributes) # rubocop:disable Rails/SkipsModelValidations + end + + def field_definitions + {} + end +end diff --git a/app/services/versioning/reverters/container_reverter.rb b/app/services/versioning/reverters/container_reverter.rb new file mode 100644 index 0000000000..19a6e3d9f6 --- /dev/null +++ b/app/services/versioning/reverters/container_reverter.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ContainerReverter < Versioning::Reverters::BaseReverter + def self.scope + Container.with_deleted + end + + def field_definitions + { + deleted_at: restore_container, + }.with_indifferent_access + end + + private + + def restore_container + lambda do |value| + return value if value.present? + + case record.container_type + when 'analysis' + generations = 2 + ancestor_id = record.parent.parent_id + when 'dataset' + analysis = Container.with_deleted.find(record.parent_id) + # if analysis.deleted? + # hierarchy = ContainerHierarchy.find_or_initialize_by( + # ancestor_id: analysis.parent.parent_id, + # descendant_id: analysis.id, + # generations: 2, + # ) + # hierarchy.save(validate: false) + # analysis.update_columns(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + # end + generations = 3 + ancestor_id = analysis.parent.parent_id + end + + hierarchy = ContainerHierarchy.find_or_initialize_by( + ancestor_id: ancestor_id, + descendant_id: record.id, + generations: generations, + ) + hierarchy.save(validate: false) + + value + end + end +end diff --git a/app/services/versioning/reverters/elemental_composition_reverter.rb b/app/services/versioning/reverters/elemental_composition_reverter.rb new file mode 100644 index 0000000000..84deda4bad --- /dev/null +++ b/app/services/versioning/reverters/elemental_composition_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ElementalCompositionReverter < Versioning::Reverters::BaseReverter + def self.scope + ElementalComposition + end +end diff --git a/app/services/versioning/reverters/reaction_reverter.rb b/app/services/versioning/reverters/reaction_reverter.rb new file mode 100644 index 0000000000..1003279d3f --- /dev/null +++ b/app/services/versioning/reverters/reaction_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ReactionReverter < Versioning::Reverters::BaseReverter + def self.scope + Reaction.with_deleted + end +end diff --git a/app/services/versioning/reverters/reactions_sample_reverter.rb b/app/services/versioning/reverters/reactions_sample_reverter.rb new file mode 100644 index 0000000000..5913ee8335 --- /dev/null +++ b/app/services/versioning/reverters/reactions_sample_reverter.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ReactionsSampleReverter < Versioning::Reverters::BaseReverter + def self.scope + ReactionsSample.with_deleted + end + + def call + super + record.reaction.save # Update svg file + end + + def field_definitions + { + deleted_at: restore_sample, + }.with_indifferent_access + end + + private + + def restore_sample + lambda do |value| + return value if value.present? + + sample = Sample.with_deleted.find(record.sample_id) + + sample.update_columns(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + sample.collections.with_deleted.update_all(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + sample.collections_samples.with_deleted.update_all(deleted_at: nil) # rubocop:disable Rails/SkipsModelValidations + + value + end + end +end diff --git a/app/services/versioning/reverters/research_plan_metadata_reverter.rb b/app/services/versioning/reverters/research_plan_metadata_reverter.rb new file mode 100644 index 0000000000..b655c232f2 --- /dev/null +++ b/app/services/versioning/reverters/research_plan_metadata_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ResearchPlanMetadataReverter < Versioning::Reverters::BaseReverter + def self.scope + ResearchPlanMetadata.with_deleted + end +end diff --git a/app/services/versioning/reverters/research_plan_reverter.rb b/app/services/versioning/reverters/research_plan_reverter.rb new file mode 100644 index 0000000000..9082d1bc87 --- /dev/null +++ b/app/services/versioning/reverters/research_plan_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ResearchPlanReverter < Versioning::Reverters::BaseReverter + def self.scope + ResearchPlan.with_deleted + end +end diff --git a/app/services/versioning/reverters/residue_reverter.rb b/app/services/versioning/reverters/residue_reverter.rb new file mode 100644 index 0000000000..173381ccbd --- /dev/null +++ b/app/services/versioning/reverters/residue_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ResidueReverter < Versioning::Reverters::BaseReverter + def self.scope + Residue + end +end diff --git a/app/services/versioning/reverters/sample_reverter.rb b/app/services/versioning/reverters/sample_reverter.rb new file mode 100644 index 0000000000..aca69b59af --- /dev/null +++ b/app/services/versioning/reverters/sample_reverter.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Versioning::Reverters::SampleReverter < Versioning::Reverters::BaseReverter + def self.scope + Sample.with_deleted + end + + def field_definitions + { + boiling_point: range, + melting_point: range, + }.with_indifferent_access + end + + private + + def range + lambda do |range_string| + lower, upper = range_string[1...-1].split(',') + Range.new( + (lower.presence || -Float::INFINITY).to_f, + (upper.presence || Float::INFINITY).to_f, + ) + end + end +end diff --git a/app/services/versioning/reverters/screen_reverter.rb b/app/services/versioning/reverters/screen_reverter.rb new file mode 100644 index 0000000000..b8aaad81d2 --- /dev/null +++ b/app/services/versioning/reverters/screen_reverter.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Versioning::Reverters::ScreenReverter < Versioning::Reverters::BaseReverter + def self.scope + Screen.with_deleted + end +end diff --git a/app/services/versioning/serializers/attachment_serializer.rb b/app/services/versioning/serializers/attachment_serializer.rb new file mode 100644 index 0000000000..8f50d5bc3a --- /dev/null +++ b/app/services/versioning/serializers/attachment_serializer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class Versioning::Serializers::AttachmentSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + filename: { + label: 'Filename', + }, + id: { + label: 'Preview', + kind: :image, + formatter: ->(_key, value) { value ? "/api/v1/attachments/image/#{value}" : '' }, + }, + }.with_indifferent_access + end +end diff --git a/app/services/versioning/serializers/base_serializer.rb b/app/services/versioning/serializers/base_serializer.rb new file mode 100644 index 0000000000..f3ec8dff0e --- /dev/null +++ b/app/services/versioning/serializers/base_serializer.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class Versioning::Serializers::BaseSerializer + include ActiveModel::Model + + attr_accessor :record, :name + + def call + Rails.cache.fetch("versions/#{record.cache_key}", version: record.cache_version) do + result = [] # result data + base = {} # track current version data + + return [] unless log_data + + log_data.versions.group_by { |version| version.data.dig('m', 'uuid') }.each do |uuid, versions| + user_id = versions.first.data.dig('m', '_r') + time = versions.first.data['ts'] / 1000 + + changes = versions.each_with_object({}) do |version, hash| + hash.merge!(version.changes) + end + + changes_comparison_hash = {} # hash for changes comparison + + revertible = changes.none? { |key, _v| key == 'created_at' } + changes.each do |key, value| + next if value == base[key] # ignore if value is same as in last version + next if blank?(base[key]) && blank?(value) # ignore if value is empty + + fields = field_definitions[key] + next if fields.nil? + + fields = [fields] unless fields.is_a?(Array) + fields.each do |field| + formatter = field[:formatter] || default_formatter + revertible_value_formatter = field[:revertible_value_formatter] || formatter + old_value = formatter.call(key, base[key]) + new_value = formatter.call(key, value) + current_value = formatter.call(key, record.read_attribute_before_type_cast(key)) + revertible_value = revertible_value_formatter.call(key, base[key]) + + next if old_value == new_value # ignore if value is same as in last version + next if old_value.blank? && new_value.blank? # ignore if value is empty or nil + + changes_comparison_hash[field[:name] || key] = { + label: field[:label], + old_value: old_value, + new_value: new_value, + current_value: current_value, + kind: field[:kind] || :string, + revert: (revertible && field[:revert]) || [], + revertible_value: revertible_value, + } + end + end + base.merge!(changes) # merge changes with last version data for next iteration + next if changes_comparison_hash.empty? + + result << { + db_id: record.id, + klass_name: klass_name, # record class (sampe, reaction, ...) + name: name, # record name (uses as default the name attribute but in case the model doesn't have a name field or you want to change it) + time: Time.zone.at(time), # timestamp of the change + user: version_user_names_lookup[user_id], # user + uuid: uuid, # request group + changes: changes_comparison_hash, # changes hash + } + end + + result + end + end + + private + + def klass_name + record.class.to_s + end + + def blank?(value) + value.blank? || value.in?(['(,)', '{}', []]) + end + + def version_user_names_lookup + @version_user_names_lookup ||= begin + ids = Set.new + + record.log_data.versions.each do |v| + ids << v.data.dig('m', '_r') + ids << v.changes['created_by'] if v.changes.key?('created_by') + ids << v.changes['created_for'] if v.changes.key?('created_for') + end + + User.with_deleted.where(id: ids).to_h { |u| [u.id, u.name] } + end + end + + delegate :log_data, to: :record + + def default_formatter + ->(key, value) { record.instance_variable_get(:@attributes)[key].type.deserialize(value) } + end + + def user_formatter + ->(_key, value) { version_user_names_lookup[value] } + end + + def jsonb_formatter(*attributes) + ->(key, value) { default_formatter.call(key, value)&.dig(*attributes) } + end + + def non_formatter + ->(_key, value) { value } + end + + def fix_malformed_value_formatter + ->(key, value) { (value || '').start_with?('{') ? YAML.safe_load(value) : default_formatter.call(key, value) } + end + + def svg_path_formatter(entity) + lambda do |key, value| + result = default_formatter.call(key, value) + return result if result.blank? + + "/images/#{entity}/#{result}" + end + end +end diff --git a/app/services/versioning/serializers/container_serializer.rb b/app/services/versioning/serializers/container_serializer.rb new file mode 100644 index 0000000000..8925c675cb --- /dev/null +++ b/app/services/versioning/serializers/container_serializer.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ContainerSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + deleted_at: { + label: 'Deleted', + kind: :boolean, + formatter: ->(_key, value) { value.present? }, + revert: %i[deleted_at], + revertible_value_formatter: default_formatter, + }, + name: { + label: 'Name', + revert: %i[name], + }, + description: { + label: 'Description', + revert: %i[description], + }, + extended_metadata: [ + { + name: 'extended_metadata.content', + label: 'Content', + kind: :quill, + formatter: lambda { |key, value| + value = fix_malformed_value_formatter.call(key, value) + JSON.parse(jsonb_formatter('content').call(key, value) || '{}') + }, + revert: %i[extended_metadata.content], + revertible_value_formatter: lambda { |key, value| + value = fix_malformed_value_formatter.call(key, value) + jsonb_formatter('content').call(key, value) || '{}' + }, + }, + # buggy + # { + # name: 'extended_metadata.index', + # label: 'Position', + # formatter: jsonb_formatter('index'), + # }, + { + name: 'extended_metadata.report', + label: 'Add to Report', + kind: :boolean, + revert: %i[extended_metadata.report], + formatter: ->(key, value) { jsonb_formatter('report').call(key, value) == 'true' }, + }, + { + name: 'extended_metadata.status', + label: 'Status', + revert: %i[extended_metadata.status], + formatter: jsonb_formatter('status'), + }, + { + name: 'extended_metadata.kind', + label: 'Type', + revert: %i[extended_metadata.kind], + formatter: jsonb_formatter('kind'), + }, + { + name: 'extended_metadata.hyperlinks', + label: 'Hyperlinks', + formatter: lambda { |key, value| + result = jsonb_formatter('hyperlinks').call(key, value) + return '' if result.blank? + + JSON.parse(result).join("\n") + }, + revert: %i[extended_metadata.hyperlinks], + revertible_value_formatter: ->(key, value) { JSON.parse(jsonb_formatter('hyperlinks').call(key, value) || '[]') }, + }, + { + name: 'extended_metadata.instrument', + label: 'Instrument', + revert: %i[extended_metadata.instrument], + formatter: jsonb_formatter('instrument'), + }, + ], + }.with_indifferent_access + end +end diff --git a/app/services/versioning/serializers/elemental_composition_serializer.rb b/app/services/versioning/serializers/elemental_composition_serializer.rb new file mode 100644 index 0000000000..cd7baa8f4d --- /dev/null +++ b/app/services/versioning/serializers/elemental_composition_serializer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ElementalCompositionSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Elemental composition']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + data: { + label: ::ElementalComposition::TYPES[record.composition_type.to_sym], + formatter: data_formatter, + revert: (record.composition_type == 'found' ? %i[data] : []), + revertible_value_formatter: default_formatter, + }, + }.with_indifferent_access + end + + private + + def data_formatter + lambda do |key, value| + value = fix_malformed_value_formatter.call(key, value) + return '' if value.blank? + + value.map { |k, v| "#{k}: #{v}" }.join(', ') + end + end +end diff --git a/app/services/versioning/serializers/reaction_serializer.rb b/app/services/versioning/serializers/reaction_serializer.rb new file mode 100644 index 0000000000..0e79e33782 --- /dev/null +++ b/app/services/versioning/serializers/reaction_serializer.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ReactionSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Properties']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + status: { + label: 'Status', + revert: %i[status], + }, + temperature: { + label: 'Temperature', + kind: :temperature, + revert: %i[temperature], + }, + reaction_svg_file: { + label: 'Structural formula', + kind: :image, + formatter: svg_path_formatter('reactions'), + }, + rxno: { + label: 'Type (Name Reaction Ontology)', + revert: %i[rxno], + formatter: ->(key, value) { default_formatter.call(key, value).to_s.split(' | ', 2)[1] }, + revertible_value_formatter: default_formatter, + }, + role: { + label: 'Role', + revert: %i[role], + }, + dangerous_products: { + label: 'Dangerous Products', + revert: %i[dangerous_products], + formatter: ->(key, value) { (default_formatter.call(key, value) || []).join(', ') }, + revertible_value_formatter: default_formatter, + }, + rf_value: { + label: 'Rf-Value', + revert: %i[rf_value], + }, + tlc_solvents: { + label: 'Solvents (parts)', + revert: %i[tlc_solvents], + }, + tlc_description: { + label: 'TLC-Description', + revert: %i[tlc_description], + }, + description: { + label: 'Description', + revert: %i[description], + kind: :quill, + }, + purification: { + label: 'Purification', + revert: %i[purification], + }, + observation: { + label: 'Additional information for publication and purification details', + revert: %i[observation], + kind: :quill, + }, + duration: { + label: 'Duration', + revert: %i[duration], + }, + timestamp_start: { + label: 'Start', + revert: %i[timestamp_start], + }, + timestamp_stop: { + label: 'Stop', + revert: %i[timestamp_stop], + }, + }.with_indifferent_access + end +end diff --git a/app/services/versioning/serializers/reactions_sample_serializer.rb b/app/services/versioning/serializers/reactions_sample_serializer.rb new file mode 100644 index 0000000000..0e6e80659a --- /dev/null +++ b/app/services/versioning/serializers/reactions_sample_serializer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ReactionsSampleSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + deleted_at: { + label: 'Deleted', + kind: :boolean, + formatter: ->(_key, value) { value.present? }, + revert: %i[deleted_at], + revertible_value_formatter: default_formatter, + }, + show_label: { + label: 'L/S', + kind: :boolean, + revert: %i[show_label], + }, + position: { + label: 'Position', + revert: %i[position], + formatter: ->(_key, value) { (value && (value + 1)) || '' }, + revertible_formatter: default_formatter, + }, + coefficient: { + label: 'Coeff', + revert: %i[coefficient], + }, + equivalent: { + label: product? ? 'Yield' : 'Equiv', + revert: %i[equivalent], + }, + reference: { + label: 'Ref', + revert: %i[reference], + kind: :boolean, + }, + waste: { + label: product? ? 'Waste' : 'Recyclable', + revert: %i[waste], + kind: :boolean, + }, + }.with_indifferent_access + end + + private + + def klass_name + 'ReactionsSample' + end + + def product? + record.type == 'ReactionsProductSample' + end +end diff --git a/app/services/versioning/serializers/research_plan_metadata_serializer.rb b/app/services/versioning/serializers/research_plan_metadata_serializer.rb new file mode 100644 index 0000000000..43f6705e94 --- /dev/null +++ b/app/services/versioning/serializers/research_plan_metadata_serializer.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ResearchPlanMetadataSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + doi: { + label: 'DOI', + revert: %i[doi], + }, + url: { + label: 'URL', + revert: %i[url], + }, + landing_page: { + label: 'Landing Page', + revert: %i[landing_page], + }, + title: { + label: 'Title', + revert: %i[title], + }, + subject: { + label: 'Subject', + revert: %i[subject], + }, + data_cite_state: { + label: 'State', + revert: %i[data_cite_state], + }, + format: { + label: 'Format', + revert: %i[format], + }, + version: { + label: 'Version', + revert: %i[version], + }, + alternate_identifier: { + label: 'Alternate Identifiers', + revert: %i[alternate_identifier], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + description: { + label: 'Descriptions', + revert: %i[description], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + geo_location: { + label: 'Geolocations', + revert: %i[geo_location], + kind: :json, + formatter: lambda { |key, value| + result = default_formatter.call(key, value) || [] + + result.flat_map do |json| + json.values.map do |array| + array.map do |k, v| + { + title: k.capitalize, + content: v, + kind: :string, + } + end + end + end + }, + }, + funding_reference: { + label: 'Funding References', + revert: %i[funding_reference], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + related_identifier: { + label: 'Related Identifiers', + revert: %i[related_identifier], + kind: :json, + formatter: json_array_formatter_with_parsed_keys, + }, + }.with_indifferent_access + end + + private + + def json_array_formatter_with_parsed_keys + lambda do |key, value| + result = default_formatter.call(key, value) || [] + + result.map do |json| + json.map do |k, v| + { + title: json_keys_dictionary[k], + content: v, + kind: :string, + } + end + # json.transform_keys { |k| json_keys_dictionary[k] } + end + end + end + + def json_keys_dictionary + { + 'alternateIdentifier' => 'Alternate Identifier', + 'alternateIdentifierType' => 'Type', + 'description' => 'Description', + 'descriptionType' => 'Type', + 'funderName' => 'Funder Name', + 'funderIdentifier' => 'Funder Identifier', + 'relatedIdentifier' => 'Related Identifier', + 'relatedIdentifierType' => 'Type', + } + end +end diff --git a/app/services/versioning/serializers/research_plan_serializer.rb b/app/services/versioning/serializers/research_plan_serializer.rb new file mode 100644 index 0000000000..23d24b726a --- /dev/null +++ b/app/services/versioning/serializers/research_plan_serializer.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ResearchPlanSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + created_by: { + label: 'Created by', + formatter: user_formatter, + }, + body: { + label: 'Content', + formatter: body_formatter, + revertible_value_formatter: default_formatter, + revert: %i[body], + kind: :json, + }, + }.with_indifferent_access + end + + private + + def body_formatter + lambda do |key, value| + result = default_formatter.call(key, value) || [] + + result.map do |item| + response = [] + + response << { + title: body_content_label(item), + content: body_content_content(item), + kind: body_content_kind(item), + } + + if item['type'] == 'image' + response << { + title: 'Zoom', + content: item.dig('value', 'zoom') || '', + kind: 'string', + } + end + + response + end + end + end + + def body_content_label(item) + { + richtext: item['title'], + ketcher: 'Ketcher schema', + image: 'Image', + table: 'Table', + sample: 'Sample', + reaction: 'Reaction', + }.with_indifferent_access[item['type']] || '' + end + + def body_content_kind(item) + { + richtext: 'quill', + ketcher: 'image', + image: 'image', + table: 'table', + sample: 'image', + reaction: 'image', + }.with_indifferent_access[item['type']] || 'string' + end + + def body_content_content(item) + case item['type'] + when 'ketcher' + "/images/research_plans/#{item.dig('value', 'svg_file')}" + when 'image' + id = lookups[:attachments][item.dig('value', 'public_name')] + id ? "/api/v1/attachments/image/#{id}" : '' + when 'sample' + "/images/samples/#{lookups[:samples][item.dig('value', 'sample_id')]}" + when 'reaction' + "/images/reactions/#{lookups[:reactions][item.dig('value', 'reaction_id')]}" + else + item['value'] + end + end + + def lookups + @lookups ||= begin + lookups = { + sample_ids: Set.new, + reaction_ids: Set.new, + identifiers: Set.new, + } + + record.log_data.versions.each do |v| + next unless v.changes['body'] + + changes = v.changes['body'].is_a?(String) ? JSON.parse(v.changes['body']) : v.changes['body'] + changes.each do |change| + case change['type'] + when 'sample' + lookups[:sample_ids] << change.dig('value', 'sample_id') + when 'reaction' + lookups[:reaction_ids] << change.dig('value', 'reaction_id') + when 'image' + lookups[:identifiers] << change.dig('value', 'public_name') + end + end + end + + { + samples: Sample.with_deleted.where(id: lookups[:sample_ids]).to_h { |s| [s.id, s.sample_svg_file] }, + reactions: Reaction.with_deleted.where(id: lookups[:reaction_ids]).to_h { |s| [s.id, s.reaction_svg_file] }, + attachments: Attachment.where(identifier: lookups[:identifiers]).to_h { |s| [s.identifier, s.id] }, + } + end + end +end diff --git a/app/services/versioning/serializers/residue_serializer.rb b/app/services/versioning/serializers/residue_serializer.rb new file mode 100644 index 0000000000..4651255666 --- /dev/null +++ b/app/services/versioning/serializers/residue_serializer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ResidueSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Polymer section']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + custom_info: [ + { + name: 'custom_info.polymer_type', + label: 'Polymer type', + formatter: jsonb_formatter('polymer_type'), + revert: %i[custom_info.polymer_type custom_info.surface_type], + }, + { + name: 'custom_info.surface_type', + label: 'Surface type', + formatter: jsonb_formatter('surface_type'), + revert: %i[custom_info.surface_type custom_info.polymer_type], + }, + { + name: 'custom_info.cross_linkage', + label: 'Cross-linkage', + formatter: jsonb_formatter('cross_linkage'), + revert: %i[custom_info.cross_linkage], + }, + { + name: 'custom_info.formula', + label: 'Cross-linkage', + formatter: jsonb_formatter('formula'), + revert: %i[custom_info.formula], + }, + { + name: 'custom_info.loading', + label: 'Loading (mmol/g)', + formatter: jsonb_formatter('loading'), + revert: %i[custom_info.loading], + }, + { + name: 'custom_info.loading_type', + label: 'Loading according to', + formatter: loading_type_formatter, + revert: %i[custom_info.loading_type], + }, + ], + }.with_indifferent_access + end + + private + + def loading_type_formatter + lambda do |key, value| + value = jsonb_formatter('loading_type').call(key, value) + { + 'mass_diff' => 'Mass difference', + 'full_conv' => '100% conversion', + 'found' => 'Elemental analyses', + 'external' => 'External estimation', + }[value] + end + end +end diff --git a/app/services/versioning/serializers/sample_serializer.rb b/app/services/versioning/serializers/sample_serializer.rb new file mode 100644 index 0000000000..127e662260 --- /dev/null +++ b/app/services/versioning/serializers/sample_serializer.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +class Versioning::Serializers::SampleSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Properties']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + description: { + label: 'Description', + revert: %i[description], + }, + created_by: { + label: 'Created by', + formatter: user_formatter, + }, + sample_svg_file: { + label: 'Structural formula', + kind: :image, + formatter: svg_path_formatter('samples'), + revertible_value_formatter: default_formatter, + revert: %i[sample_svg_file molfile molecule_id fingerprint_id], + }, + stereo: [ + { + name: 'stereo.abs', + label: 'Stereo Abs', + formatter: jsonb_formatter('abs'), + revert: %i[stereo.abs], + }, + { + name: 'stereo.rel', + label: 'Stereo Rel', + formatter: jsonb_formatter('rel'), + revert: %i[stereo.rel], + }, + ], + is_top_secret: { + label: 'Top secret', + kind: :boolean, + revert: %i[is_top_secret], + }, + external_label: { + label: 'External label', + revert: %i[external_label], + }, + boiling_point: { + label: 'Boiling point', + kind: :numrange, + revert: %i[boiling_point], + formatter: non_formatter, + }, + melting_point: { + label: 'Melting point', + kind: :numrange, + revert: %i[melting_point], + formatter: non_formatter, + }, + purity: { + label: 'Purity/Concentration', + revert: %i[purity density], + }, + density: { + label: 'Density', + revert: %i[density purity], + }, + molarity_value: { + label: 'Molarity', + revert: %i[molarity_value], + }, + target_amount_value: { + label: 'Amount', + revert: %i[target_amount_value target_amount_unit], + }, + target_amount_unit: { + label: 'Target amount unit', + revert: %i[target_amount_unit target_amount_value], + }, + location: { + label: 'Location', + revert: %i[location], + }, + molfile: { + label: 'Molfile', + }, + metrics: { + label: 'Amount metrics', + revert: %i[metrics], + formatter: metrics_formatter, + revertible_value_formatter: default_formatter, + }, + xref: [ + { + name: 'xref.cas', + label: 'CAS', + revert: %i[xref.cas], + formatter: jsonb_formatter('cas', 'value'), + }, + ], + solvent: { + label: 'Solvent', + kind: :solvent, + revert: %i[solvent], + }, + molecule_name_id: { + label: 'Molecule', + formatter: ->(_key, value) { molecule_names_lookup[value] }, + revert: %i[molecule_name_id], + revertible_value_formatter: default_formatter, + }, + molecule_id: { + kind: :hidden, + }, + fingerprint_id: { + kind: :hidden, + }, + }.with_indifferent_access + end + + private + + def molecule_names_lookup + @molecule_names_lookup ||= begin + ids = Set.new + + record.log_data.versions.each do |v| + ids << v.changes['molecule_name_id'] if v.changes.key?('molecule_name_id') + end + + MoleculeName.with_deleted.where(id: ids).to_h { |u| [u.id, u.name] } + end + end + + def metrics_formatter + lambda do |_key, value| + return '' unless value + + first = { + 'm' => 'mg', + 'n' => 'g', + 'u' => 'μg', + }[value[0]] + + second = { + 'm' => 'ml', + 'n' => 'l', + 'u' => 'μl', + }[value[1]] + + third = { + 'm' => 'mmol', + 'n' => 'mol', + }[value[2]] + + [first, second, third].join(', ') + end + end +end diff --git a/app/services/versioning/serializers/screen_serializer.rb b/app/services/versioning/serializers/screen_serializer.rb new file mode 100644 index 0000000000..a5d151d6c4 --- /dev/null +++ b/app/services/versioning/serializers/screen_serializer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class Versioning::Serializers::ScreenSerializer < Versioning::Serializers::BaseSerializer + def self.call(record, name = ['Properties']) + new(record: record, name: name).call + end + + def field_definitions + { + created_at: { + label: 'Created at', + kind: :date, + }, + name: { + label: 'Name', + revert: %i[name], + }, + collaborator: { + label: 'Collaborator', + revert: %i[collaborator], + }, + requirements: { + label: 'Requirements', + revert: %i[requirements], + }, + conditions: { + label: 'Conditions', + revert: %i[conditions], + }, + result: { + label: 'Result', + revert: %i[result], + }, + description: { + label: 'Description', + kind: :quill, + revert: %i[description], + }, + }.with_indifferent_access + end +end diff --git a/config/initializers/logidze.rb b/config/initializers/logidze.rb index 417954c662..bbb2b1b255 100644 --- a/config/initializers/logidze.rb +++ b/config/initializers/logidze.rb @@ -7,7 +7,7 @@ module Meta def with_responsible!(responsible_id) return if responsible_id.nil? - meta = { Logidze::History::Version::META_RESPONSIBLE => responsible_id } + meta = { Logidze::History::Version::META_RESPONSIBLE => responsible_id, 'uuid' => SecureRandom.uuid } PermanentMetaWithTransaction.wrap_with(meta, &proc {}) end diff --git a/config/profile_default.yml.example b/config/profile_default.yml.example index 23616443dd..0a533148ee 100644 --- a/config/profile_default.yml.example +++ b/config/profile_default.yml.example @@ -1,11 +1,11 @@ -default: &default +development: :layout: :layout: :sample: 1 :reaction: 2 :wellplate: 3 :screen: 4 - :research_plan: + :research_plan: 5 :cell_line: -1000 :layout_detail_research_plan: :research_plan: @@ -27,8 +27,6 @@ default: &default 4 :results: 5 - :history: - 6 :layout_detail_reaction: :scheme: 1 @@ -42,8 +40,6 @@ default: &default 5 :variations: 6 - :history: - 7 :layout_detail_wellplate: :properties: 1 @@ -58,12 +54,117 @@ default: &default 1 :analyses: 2 -development: - <<: *default - production: - <<: *default + :layout: + :layout: + :sample: 1 + :reaction: 2 + :wellplate: 3 + :screen: 4 + :research_plan: 5 + :cell_line: -1000 + :layout_detail_research_plan: + :research_plan: + 1 + :analyses: + 2 + :references: + 3 + :attachments: + 4 + :layout_detail_sample: + :properties: + 1 + :analyses: + 2 + :qc_curation: + 3 + :references: + 4 + :results: + 5 + :layout_detail_reaction: + :scheme: + 1 + :properties: + 2 + :analyses: + 3 + :references: + 4 + :green_chemistry: + 5 + :variations: + 6 + :layout_detail_wellplate: + :properties: + 1 + :analyses: + 2 + :designer: + 3 + :list: + 4 + :layout_detail_screen: + :properties: + 1 + :analyses: + 2 test: - <<: *default + :layout: + :layout: + :sample: 1 + :reaction: 2 + :wellplate: 3 + :screen: 4 + :research_plan: 5 + :cell_line: -1000 + :layout_detail_research_plan: + :research_plan: + 1 + :analyses: + 2 + :references: + 3 + :attachments: + 4 + :layout_detail_sample: + :properties: + 1 + :analyses: + 2 + :qc_curation: + 3 + :references: + 4 + :results: + 5 + :layout_detail_reaction: + :scheme: + 1 + :properties: + 2 + :analyses: + 3 + :references: + 4 + :green_chemistry: + 5 + :variations: + 6 + :layout_detail_wellplate: + :properties: + 1 + :analyses: + 2 + :designer: + 3 + :list: + 4 + :layout_detail_screen: + :properties: + 1 + :analyses: + 2 diff --git a/db/migrate/20190617153000_convert_analysis_type.rb b/db/migrate/20190617153000_convert_analysis_type.rb index 7adf17ad04..1646200cf1 100644 --- a/db/migrate/20190617153000_convert_analysis_type.rb +++ b/db/migrate/20190617153000_convert_analysis_type.rb @@ -22,7 +22,7 @@ class ConvertAnalysisType < ActiveRecord::Migration[4.2] def up # convert CONV.each do |c| - list = Container.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) + list = Container.unscoped.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) list.each do |rs| meta = rs.extended_metadata meta["kind"] = c[:ols] @@ -32,7 +32,7 @@ def up end EXCP.each do |c| - list = Container.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) + list = Container.unscoped.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:kind]) list.each do |rs| meta = rs.extended_metadata meta["kind"] = c[:ols] @@ -45,7 +45,7 @@ def up def down # revert CONV.each do |c| - list = Container.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:ols]) + list = Container.unscoped.where('extended_metadata->\'kind\' = (?) and container_type = \'analysis\' ', c[:ols]) list.each do |rs| meta = rs.extended_metadata meta["kind"] = c[:kind] diff --git a/db/migrate/20221213121641_add_logidze_to_research_plans.rb b/db/migrate/20221213121641_add_logidze_to_research_plans.rb new file mode 100644 index 0000000000..8f2f45e634 --- /dev/null +++ b/db/migrate/20221213121641_add_logidze_to_research_plans.rb @@ -0,0 +1,17 @@ +class AddLogidzeToResearchPlans < ActiveRecord::Migration[6.1] + def change + add_column :research_plans, :log_data, :jsonb + + reversible do |dir| + dir.up do + create_trigger :logidze_on_research_plans, on: :research_plans + end + + dir.down do + execute <<~SQL + DROP TRIGGER IF EXISTS "logidze_on_research_plans" on "research_plans"; + SQL + end + end + end +end diff --git a/db/migrate/20221213123310_add_logidze_to_research_plan_metadata.rb b/db/migrate/20221213123310_add_logidze_to_research_plan_metadata.rb new file mode 100644 index 0000000000..ed0983d4ba --- /dev/null +++ b/db/migrate/20221213123310_add_logidze_to_research_plan_metadata.rb @@ -0,0 +1,17 @@ +class AddLogidzeToResearchPlanMetadata < ActiveRecord::Migration[6.1] + def change + add_column :research_plan_metadata, :log_data, :jsonb + + reversible do |dir| + dir.up do + create_trigger :logidze_on_research_plan_metadata, on: :research_plan_metadata + end + + dir.down do + execute <<~SQL + DROP TRIGGER IF EXISTS "logidze_on_research_plan_metadata" on "research_plan_metadata"; + SQL + end + end + end +end diff --git a/db/migrate/20221216222832_remove_default_for_reactions_samples_timestamps.rb b/db/migrate/20221216222832_remove_default_for_reactions_samples_timestamps.rb new file mode 100644 index 0000000000..78174af525 --- /dev/null +++ b/db/migrate/20221216222832_remove_default_for_reactions_samples_timestamps.rb @@ -0,0 +1,6 @@ +class RemoveDefaultForReactionsSamplesTimestamps < ActiveRecord::Migration[6.1] + def change + change_column_default(:reactions_samples, :created_at, from: '2021-10-1T00:00:00', to: nil) + change_column_default(:reactions_samples, :updated_at, from: '2021-10-1T00:00:00', to: nil) + end +end diff --git a/db/migrate/20221219194720_add_logidze_to_screens.rb b/db/migrate/20221219194720_add_logidze_to_screens.rb new file mode 100644 index 0000000000..cbde62143b --- /dev/null +++ b/db/migrate/20221219194720_add_logidze_to_screens.rb @@ -0,0 +1,17 @@ +class AddLogidzeToScreens < ActiveRecord::Migration[6.1] + def change + add_column :screens, :log_data, :jsonb + + reversible do |dir| + dir.up do + create_trigger :logidze_on_screens, on: :screens + end + + dir.down do + execute <<~SQL + DROP TRIGGER IF EXISTS "logidze_on_screens" on "screens"; + SQL + end + end + end +end diff --git a/db/migrate/20230120134152_add_deleted_at_to_containers.rb b/db/migrate/20230120134152_add_deleted_at_to_containers.rb new file mode 100644 index 0000000000..1a849280f0 --- /dev/null +++ b/db/migrate/20230120134152_add_deleted_at_to_containers.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToContainers < ActiveRecord::Migration[6.1] + def change + add_column :containers, :deleted_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index b7b91efe07..055254bd4a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -70,9 +70,9 @@ t.string "attachable_type" t.string "aasm_state" t.bigint "filesize" - t.jsonb "log_data" t.jsonb "attachment_data" t.integer "con_state" + t.jsonb "log_data" t.index ["attachable_type", "attachable_id"], name: "index_attachments_on_attachable_type_and_attachable_id" t.index ["identifier"], name: "index_attachments_on_identifier", unique: true end @@ -343,6 +343,7 @@ t.integer "parent_id" t.text "plain_text_content" t.jsonb "log_data" + t.datetime "deleted_at" t.index ["containable_type", "containable_id"], name: "index_containers_on_containable" end @@ -1072,6 +1073,7 @@ t.text "subject" t.jsonb "alternate_identifier" t.jsonb "related_identifier" + t.jsonb "log_data" t.index ["deleted_at"], name: "index_research_plan_metadata_on_deleted_at" t.index ["research_plan_id"], name: "index_research_plan_metadata_on_research_plan_id" end @@ -1092,6 +1094,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.jsonb "body" + t.jsonb "log_data" end create_table "research_plans_screens", force: :cascade do |t| @@ -1220,6 +1223,7 @@ t.datetime "deleted_at" t.jsonb "component_graph_data", default: {} t.text "plain_text_description" + t.jsonb "log_data" t.index ["deleted_at"], name: "index_screens_on_deleted_at" end @@ -2101,6 +2105,9 @@ create_trigger :logidze_on_samples, sql_definition: <<-SQL CREATE TRIGGER logidze_on_samples BEFORE INSERT OR UPDATE ON public.samples FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL + create_trigger :logidze_on_screens, sql_definition: <<-SQL + CREATE TRIGGER logidze_on_screens BEFORE INSERT OR UPDATE ON public.screens FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') + SQL create_trigger :logidze_on_residues, sql_definition: <<-SQL CREATE TRIGGER logidze_on_residues BEFORE INSERT OR UPDATE ON public.residues FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL @@ -2113,12 +2120,18 @@ create_trigger :logidze_on_attachments, sql_definition: <<-SQL CREATE TRIGGER logidze_on_attachments BEFORE INSERT OR UPDATE ON public.attachments FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL + create_trigger :logidze_on_research_plans, sql_definition: <<-SQL + CREATE TRIGGER logidze_on_research_plans BEFORE INSERT OR UPDATE ON public.research_plans FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') + SQL create_trigger :logidze_on_reactions_samples, sql_definition: <<-SQL CREATE TRIGGER logidze_on_reactions_samples BEFORE INSERT OR UPDATE ON public.reactions_samples FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') SQL create_trigger :update_users_matrix_trg, sql_definition: <<-SQL CREATE TRIGGER update_users_matrix_trg AFTER INSERT OR UPDATE ON public.matrices FOR EACH ROW EXECUTE FUNCTION update_users_matrix() SQL + create_trigger :logidze_on_research_plan_metadata, sql_definition: <<-SQL + CREATE TRIGGER logidze_on_research_plan_metadata BEFORE INSERT OR UPDATE ON public.research_plan_metadata FOR EACH ROW WHEN ((COALESCE(current_setting('logidze.disabled'::text, true), ''::text) <> 'on'::text)) EXECUTE FUNCTION logidze_logger('null', 'updated_at') + SQL create_view "v_samples_collections", sql_definition: <<-SQL SELECT cols.id AS cols_id, diff --git a/db/triggers/logidze_on_research_plan_metadata_v01.sql b/db/triggers/logidze_on_research_plan_metadata_v01.sql new file mode 100644 index 0000000000..1e4d4cd4b9 --- /dev/null +++ b/db/triggers/logidze_on_research_plan_metadata_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_research_plan_metadata" +BEFORE UPDATE OR INSERT ON "research_plan_metadata" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_research_plans_v01.sql b/db/triggers/logidze_on_research_plans_v01.sql new file mode 100644 index 0000000000..9d00d471d7 --- /dev/null +++ b/db/triggers/logidze_on_research_plans_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_research_plans" +BEFORE UPDATE OR INSERT ON "research_plans" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_research_plans_wellplates_v01.sql b/db/triggers/logidze_on_research_plans_wellplates_v01.sql new file mode 100644 index 0000000000..a9b2606155 --- /dev/null +++ b/db/triggers/logidze_on_research_plans_wellplates_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_research_plans_wellplates" +BEFORE UPDATE OR INSERT ON "research_plans_wellplates" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_screens_v01.sql b/db/triggers/logidze_on_screens_v01.sql new file mode 100644 index 0000000000..4904dbd7c0 --- /dev/null +++ b/db/triggers/logidze_on_screens_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_screens" +BEFORE UPDATE OR INSERT ON "screens" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/db/triggers/logidze_on_wellplates_v01.sql b/db/triggers/logidze_on_wellplates_v01.sql new file mode 100644 index 0000000000..6ac6dd7263 --- /dev/null +++ b/db/triggers/logidze_on_wellplates_v01.sql @@ -0,0 +1,6 @@ +CREATE TRIGGER "logidze_on_wellplates" +BEFORE UPDATE OR INSERT ON "wellplates" FOR EACH ROW +WHEN (coalesce(current_setting('logidze.disabled', true), '') <> 'on') +-- Parameters: history_size_limit (integer), timestamp_column (text), filtered_columns (text[]), +-- include_columns (boolean), debounce_time_ms (integer) +EXECUTE PROCEDURE logidze_logger(null, 'updated_at'); diff --git a/yarn.lock b/yarn.lock index 11131729a2..dc57ecf084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2137,14 +2137,30 @@ resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8" integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw== dependencies: - "@babel/code-frame" "^7.22.13" - "@babel/generator" "^7.23.0" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.0" - "@babel/types" "^7.23.0" + "@babel/code-frame" "^7.16.7" + "@babel/generator" "^7.16.8" + "@babel/helper-environment-visitor" "^7.16.7" + "@babel/helper-function-name" "^7.16.7" + "@babel/helper-hoist-variables" "^7.16.7" + "@babel/helper-split-export-declaration" "^7.16.7" + "@babel/parser" "^7.16.10" + "@babel/types" "^7.16.8" + debug "^4.1.0" + globals "^11.1.0" + +"@babel/traverse@^7.20.1": + version "7.20.1" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.1.tgz#9b15ccbf882f6d107eeeecf263fbcdd208777ec8" + integrity sha512-d3tN8fkVJwFLkHkBN479SOsw4DMZnz8cdbL/gvuDuzy3TS6Nfw80HuQqhw1pITbIruHyh7d1fMA47kWzmcUEGA== + dependencies: + "@babel/code-frame" "^7.18.6" + "@babel/generator" "^7.20.1" + "@babel/helper-environment-visitor" "^7.18.9" + "@babel/helper-function-name" "^7.19.0" + "@babel/helper-hoist-variables" "^7.18.6" + "@babel/helper-split-export-declaration" "^7.18.6" + "@babel/parser" "^7.20.1" + "@babel/types" "^7.20.0" debug "^4.1.0" globals "^11.1.0" @@ -13634,10 +13650,10 @@ react-transition-group@^2.0.0, react-transition-group@^2.2.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" -react-transition-group@^4.2.0, react-transition-group@^4.4.0, react-transition-group@^4.4.5: - version "4.4.5" - resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" - integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== +react-transition-group@^4.2.0: + version "4.4.2" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.2.tgz#8b59a56f09ced7b55cbd53c36768b922890d5470" + integrity sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg== dependencies: "@babel/runtime" "^7.5.5" dom-helpers "^5.0.1" @@ -13654,6 +13670,16 @@ react-transition-group@^4.3.0: loose-envify "^1.4.0" prop-types "^15.6.2" +react-transition-group@^4.4.0, react-transition-group@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-ui-tree@3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/react-ui-tree/-/react-ui-tree-3.1.0.tgz"