diff --git a/app/api/api.rb b/app/api/api.rb index 219124793e..bb234a587e 100644 --- a/app/api/api.rb +++ b/app/api/api.rb @@ -138,7 +138,7 @@ def to_json_camel_case(val) TARGET = Rails.env.production? ? 'https://www.chemotion-repository.net/' : 'http://localhost:3000/' - ELEMENTS = %w[research_plan screen wellplate reaction sample cell_line].freeze + ELEMENTS = %w[research_plan screen wellplate reaction sample cell_line device_description].freeze ELEMENT_CLASS = { 'research_plan' => ResearchPlan, @@ -147,6 +147,7 @@ def to_json_camel_case(val) 'reaction' => Reaction, 'sample' => Sample, 'cell_line' => CelllineSample, + 'device_description' => DeviceDescription, }.freeze mount Chemotion::LiteratureAPI @@ -203,6 +204,7 @@ def to_json_camel_case(val) mount Chemotion::AdminDeviceAPI mount Chemotion::AdminDeviceMetadataAPI mount Chemotion::ChemicalAPI + mount Chemotion::DeviceDescriptionAPI add_swagger_documentation(info: { title: 'Chemotion ELN', diff --git a/app/api/chemotion/attachable_api.rb b/app/api/chemotion/attachable_api.rb index facfeb907c..1fdc0f9821 100644 --- a/app/api/chemotion/attachable_api.rb +++ b/app/api/chemotion/attachable_api.rb @@ -48,7 +48,9 @@ class AttachableAPI < Grape::API begin a.save! attach_ary.push(a.id) - rp_attach_ary.push(a.id) if a.attachable_type.in?(%w[ResearchPlan Wellplate Labimotion::Element]) + if a.attachable_type.in?(%w[ResearchPlan Wellplate DeviceDescription Labimotion::Element]) + rp_attach_ary.push(a.id) + end ensure tempfile.close tempfile.unlink diff --git a/app/api/chemotion/attachment_api.rb b/app/api/chemotion/attachment_api.rb index c7c1c4d16a..2456910af2 100644 --- a/app/api/chemotion/attachment_api.rb +++ b/app/api/chemotion/attachment_api.rb @@ -82,6 +82,7 @@ def remove_duplicated(att) @attachment = Attachment.find_by(identifier: params[:identifier]) if @attachment.nil? && params[:identifier] + # rubocop:disable Performance/StringInclude case request.env['REQUEST_METHOD'] when /delete/i error!('401 Unauthorized', 401) unless writable?(@attachment) @@ -101,6 +102,13 @@ def remove_duplicated(att) can_dwnld = can_read && ElementPermissionProxy.new(current_user, element, user_ids).read_dataset? end + elsif /device_description_analyses/.match?(request.url) + @device_description = DeviceDescription.find(params[:device_description_id]) + if (element = @device_description) + can_read = ElementPolicy.new(current_user, element).read? + can_dwnld = can_read && + ElementPermissionProxy.new(current_user, element, user_ids).read_dataset? + end elsif @attachment can_dwnld = @attachment.container_id.nil? && @attachment.created_for == current_user.id @@ -119,6 +127,7 @@ def remove_duplicated(att) end error!('401 Unauthorized', 401) unless can_dwnld end + # rubocop:enable Performance/StringInclude end desc 'Bulk Delete Attachments' @@ -347,20 +356,43 @@ def remove_duplicated(att) end end&.flatten&.reduce(:+) || 0 if tts > 300_000_000 - DownloadAnalysesJob.perform_later(@sample.id, current_user.id, false) + DownloadAnalysesJob.perform_later(@sample.id, current_user.id, false, 'sample') nil else env['api.format'] = :binary content_type('application/zip, application/octet-stream') filename = CGI.escape("#{@sample.short_label}-analytical-files.zip") header('Content-Disposition', "attachment; filename=\"#{filename}\"") - zip = DownloadAnalysesJob.perform_now(@sample.id, current_user.id, true) + zip = DownloadAnalysesJob.perform_now(@sample.id, current_user.id, true, 'sample') zip.rewind zip.read end end + desc 'Download the zip attachment file by device_description_id' + get 'device_description_analyses/:device_description_id' do + # rubocop:disable Performance/Sum + tts = @device_description.analyses&.map do |a| + a.children&.map do |d| + d.attachments&.map(&:filesize) + end + end&.flatten&.reduce(:+) || 0 + # rubocop:enable Performance/Sum + if tts > 300_000_000 + DownloadAnalysesJob.perform_later(@device_description.id, current_user.id, false, 'device_description') + nil + else + env['api.format'] = :binary + content_type('application/zip, application/octet-stream') + filename = CGI.escape("#{@device_description.short_label}-analytical-files.zip") + header('Content-Disposition', "attachment; filename=\"#{filename}\"") + zip = DownloadAnalysesJob.perform_now(@device_description.id, current_user.id, true, 'device_description') + zip.rewind + zip.read + end + end + desc 'Return image attachment' params do diff --git a/app/api/chemotion/code_log_api.rb b/app/api/chemotion/code_log_api.rb index 79a8c99a13..42b51cccb8 100644 --- a/app/api/chemotion/code_log_api.rb +++ b/app/api/chemotion/code_log_api.rb @@ -27,15 +27,16 @@ class CodeLogAPI < Grape::API get do code = params[:code] s = code&.size || 0 - code_log = if s >= 39 - CodeLog.find(CodeCreator.digit_to_uuid(code)) - elsif s >= 8 - # TODO: use where instead of find_by ? - CodeLog.where('value ~ ?', "\\A0#{code}").first - elsif s == 6 - # TODO: use where instead of find_by ? - CodeLog.find_by(value_xs: code.to_i) - end + code_log = + if s >= 39 + CodeLog.find(CodeCreator.digit_to_uuid(code)) + elsif s >= 8 + # TODO: use where instead of find_by ? + CodeLog.where('value ~ ?', "\\A0#{code}").first + elsif s == 6 + # TODO: use where instead of find_by ? + CodeLog.find_by(value_xs: code.to_i) + end if code_log.nil? error!("Element with #{code.size}-digit code #{params[:code]} not found", 404) @@ -48,7 +49,7 @@ class CodeLogAPI < Grape::API namespace :print_codes do desc 'Build PDF with element bar & qr code' params do - requires :element_type, type: String, values: %w[sample reaction wellplate screen] + requires :element_type, type: String, values: %w[sample reaction wellplate screen device_description] # TODO: check coerce with type Array[Integer] not working with before do requires :ids, type: Array # , coerce_with: ->(val) { val.split(/,/).map(&:to_i) } requires :width, type: Integer @@ -97,7 +98,7 @@ class CodeLogAPI < Grape::API namespace :print_analyses_codes do desc 'Build PDF with analyses codes of one analysis type' params do - requires :element_type, type: String, values: %w[sample reaction wellplate screen] + requires :element_type, type: String, values: %w[sample reaction wellplate screen device_description] requires :id, type: Integer, desc: 'Element id' requires :analyses_ids, type: Array[String] requires :size, type: String, values: %w[small big] @@ -118,7 +119,7 @@ class CodeLogAPI < Grape::API content_type('application/pdf') header 'Content-Disposition', "attachment; filename*=UTF-8''analysis_codes_#{params[:size]}.pdf" env['api.format'] = :binary - # TODO: check container type/info instead + # TODO: check container type/info instead # case params[:type] # when "nmr_analysis" # body AnalysisNmrPdf.new(elements).render diff --git a/app/api/chemotion/collection_api.rb b/app/api/chemotion/collection_api.rb index e1e95ff4b8..6d712a972c 100644 --- a/app/api/chemotion/collection_api.rb +++ b/app/api/chemotion/collection_api.rb @@ -193,6 +193,9 @@ class CollectionAPI < Grape::API optional :cell_line, type: Hash do use :ui_state_params end + optional :device_description, type: Hash do + use :ui_state_params + end end requires :collection_attributes, type: Hash do requires :permission_level, type: Integer @@ -221,6 +224,9 @@ class CollectionAPI < Grape::API cell_lines = CelllineSample.by_collection_id(@cid) .by_ui_state(params[:elements_filter][:cell_line]) .for_user_n_groups(user_ids) + device_descriptions = DeviceDescription.by_collection_id(@cid) + .by_ui_state(params[:elements_filter][:device_description]) + .for_user_n_groups(user_ids) elements = {} Labimotion::ElementKlass.find_each do |klass| elements[klass.name] = Labimotion::Element.by_collection_id(@cid).by_ui_state(params[:elements_filter][klass.name]).for_user_n_groups(user_ids) @@ -237,6 +243,7 @@ class CollectionAPI < Grape::API share_screens = ElementsPolicy.new(current_user, screens).share? share_research_plans = ElementsPolicy.new(current_user, research_plans).share? share_cell_lines = ElementsPolicy.new(current_user, cell_lines).share? + share_device_descriptions = ElementsPolicy.new(current_user, device_descriptions).share? share_elements = !(elements&.length > 0) elements.each do |k, v| share_elements = ElementsPolicy.new(current_user, v).share? @@ -249,6 +256,7 @@ class CollectionAPI < Grape::API share_screens && share_research_plans && share_cell_lines && + share_device_descriptions && share_elements error!('401 Unauthorized', 401) if (!sharing_allowed || is_top_secret) @@ -258,6 +266,7 @@ class CollectionAPI < Grape::API @screen_ids = screens.pluck(:id) @research_plan_ids = research_plans.pluck(:id) @cell_line_ids = cell_lines.pluck(:id) + @device_description_ids = device_descriptions.pluck(:id) @element_ids = elements&.transform_values { |v| v && v.pluck(:id) } end @@ -280,6 +289,7 @@ class CollectionAPI < Grape::API screen_ids: @screen_ids, research_plan_ids: @research_plan_ids, cell_line_ids: @cell_line_ids, + device_description_ids: @device_description_ids, element_ids: @element_ids, collection_attributes: params[:collection_attributes].merge(shared_by_id: current_user.id) ).execute! diff --git a/app/api/chemotion/comment_api.rb b/app/api/chemotion/comment_api.rb index 85660467b9..74e6a27313 100644 --- a/app/api/chemotion/comment_api.rb +++ b/app/api/chemotion/comment_api.rb @@ -100,6 +100,7 @@ def find_commentable(commentable_type, commentable_id) Comment.wellplate_sections.values + Comment.screen_sections.values + Comment.research_plan_sections.values + + Comment.device_description_sections.values + Comment.header_sections.values end diff --git a/app/api/chemotion/device_description_api.rb b/app/api/chemotion/device_description_api.rb new file mode 100644 index 0000000000..270e523a6d --- /dev/null +++ b/app/api/chemotion/device_description_api.rb @@ -0,0 +1,312 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ClassLength +module Chemotion + class DeviceDescriptionAPI < Grape::API + include Grape::Kaminari + helpers ContainerHelpers + helpers ParamsHelpers + helpers CollectionHelpers + + helpers do + params :create_params do + requires :collection_id, type: Integer + end + + params :update_params do + requires :id, type: Integer + optional :device_id, type: Integer + end + + params :default_params do + optional :name, type: String + optional :short_label, type: String + optional :device_type, type: String + optional :device_type_detail, type: String + optional :operation_mode, type: String + optional :vendor_device_name, type: String + optional :vendor_device_id, type: String + optional :serial_number, type: String + optional :vendor_company_name, type: String + optional :vendor_id, type: String + optional :description, type: String + optional :general_tags, type: Array + optional :version_number, type: String + optional :version_installation_start_date, type: DateTime, allow_blank: true + optional :version_installation_end_date, type: DateTime, allow_blank: true + optional :version_identifier_type, type: String + optional :version_doi, type: String + optional :version_doi_url, type: String + optional :version_characterization, type: String + optional :operators, type: Array do + optional :name, type: String + optional :phone, type: String + optional :email, type: String + optional :type, type: String + optional :comment, type: String + end + optional :university_campus, type: String + optional :institute, type: String + optional :building, type: String + optional :room, type: String + optional :infrastructure_assignment, type: String + optional :access_options, type: String + optional :access_comments, type: String + optional :size, type: String + optional :weight, type: String + optional :weight_unit, type: String + optional :application_name, type: String + optional :application_version, type: String + optional :vendor_url, type: String + optional :helpers_uploaded, type: Boolean + optional :policies_and_user_information, type: String + optional :description_for_methods_part, type: String + optional :container, type: Hash + optional :ontologies, type: Array do + optional :data, type: Hash + optional :paths, type: Array + optional :segments, type: Array + optional :index, type: Integer + end + optional :segments, type: Array + optional :setup_descriptions, type: Hash + optional :maintenance_contract_available, type: String + optional :maintenance_scheduling, type: String + optional :contact_for_maintenance, type: Array do + optional :company, type: String + optional :contact, type: String + optional :email, type: String + optional :phone, type: String + optional :comment, type: String + end + optional :planned_maintenance, type: Array do + optional :date, type: Date + optional :type, type: String + optional :details, type: String + optional :status, type: String + optional :costs, type: Float + optional :time, type: String + optional :changes, type: String + end + optional :consumables_needed_for_maintenance, type: Array do + optional :name, type: String + optional :type, type: String + optional :number, type: Integer + optional :status, type: String + optional :costs, type: Float + optional :details, type: String + end + optional :unexpected_maintenance, type: Array do + optional :date, type: Date + optional :type, type: String + optional :details, type: String + optional :status, type: String + optional :costs, type: Float + optional :time, type: String + optional :changes, type: String + end + optional :measures_after_full_shut_down, type: String + optional :measures_after_short_shut_down, type: String + optional :measures_to_plan_offline_period, type: String + optional :restart_after_planned_offline_period, type: String + end + + def device_description_with_entity(device_description) + @element_policy = ElementPolicy.new(current_user, device_description) + present( + device_description, + with: Entities::DeviceDescriptionEntity, + detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: device_description) + .detail_levels, + policy: @element_policy, + root: :device_description, + ) + end + end + + resource :device_descriptions do + # Return serialized device description by collection id + params do + optional :collection_id, type: Integer + optional :sync_collection_id, type: Integer + optional :filter_created_at, type: Boolean, desc: 'filter by created at or updated at' + optional :from_date, type: Integer, desc: 'created_date from in ms' + optional :to_date, type: Integer, desc: 'created_date to in ms' + end + paginate per_page: 5, offset: 0 + before do + params[:per_page].to_i > 50 && (params[:per_page] = 50) + end + get do + scope = + if params[:collection_id] + begin + Collection.belongs_to_or_shared_by(current_user.id, current_user.group_ids) + .find(params[:collection_id]).device_descriptions + rescue ActiveRecord::RecordNotFound + DeviceDescription.none + end + elsif params[:sync_collection_id] + begin + current_user.all_sync_in_collections_users.find(params[:sync_collection_id]) + .collection.device_descriptions + rescue ActiveRecord::RecordNotFound + DeviceDescription.none + end + else + # All collection of current_user + DeviceDescription.joins(:collections) + .where(collections: { user_id: current_user.id }).distinct + end + scope.order('created_at DESC') + + from = params[:from_date] + to = params[:to_date] + by_created_at = params[:filter_created_at] || false + + scope = scope.includes_for_list_display + scope = scope.created_time_from(Time.zone.at(from)) if from && by_created_at + scope = scope.created_time_to(Time.zone.at(to) + 1.day) if to && by_created_at + scope = scope.updated_time_from(Time.zone.at(from)) if from && !by_created_at + scope = scope.updated_time_to(Time.zone.at(to) + 1.day) if to && !by_created_at + + reset_pagination_page(scope) + + device_descriptions = paginate(scope).map do |device_description| + Entities::DeviceDescriptionEntity.represent( + device_description, + displayed_in_list: true, + detail_levels: ElementDetailLevelCalculator.new(user: current_user, element: device_description) + .detail_levels, + ) + end + + { device_descriptions: device_descriptions } + end + + # create a device description + params do + use :default_params + use :create_params + end + post do + attributes = declared(params.except(:container), include_missing: false) + attributes[:created_by] = current_user.id + device_description = Usecases::DeviceDescriptions::Create.new(attributes, current_user).execute + device_description.container = update_datamodel(params[:container]) if params[:container].present? + device_description_with_entity(device_description) + rescue ActiveRecord::RecordInvalid + { errors: device_description.errors.messages } + end + + # get segment klass ids by new ontology + namespace :byontology do + params do + requires :id, type: Integer + requires :ontology, type: Hash do + optional :data, type: Hash + optional :paths, type: Array + end + end + put ':id' do + device_description = DeviceDescription.find(params[:id]) + attributes = declared(params, include_missing: false) + segment_klass_ids = + Usecases::DeviceDescriptions::Update.new(attributes, device_description, current_user) + .segment_klass_ids_by_new_ontology + segment_klass_ids + end + end + + # get device descriptions by UI state + namespace :ui_state do + params do + requires :ui_state, type: Hash, desc: 'Selected device descriptions from the UI' do + optional :all, type: Boolean + optional :included_ids, type: Array + optional :excluded_ids, type: Array + optional :from_date, type: Date + optional :to_date, type: Date + optional :collection_id, type: Integer + optional :is_sync_to_me, type: Boolean, default: false + end + optional :limit, type: Integer, desc: 'Limit number of device descriptions' + end + + before do + cid = fetch_collection_id_w_current_user(params[:ui_state][:collection_id], params[:ui_state][:is_sync_to_me]) + @device_descriptions = + DeviceDescription.by_collection_id(cid).by_ui_state(params[:ui_state]).for_user(current_user.id) + error!('401 Unauthorized', 401) unless ElementsPolicy.new(current_user, @device_descriptions).read? + end + + post do + @device_descriptions = @device_descriptions.limit(params[:limit]) if params[:limit] + + present @device_descriptions, with: Entities::DeviceDescriptionEntity, root: :device_descriptions + end + end + + # split device description into sub device description + namespace :sub_device_descriptions do + params do + requires :ui_state, type: Hash, desc: 'Selected device descriptions from the UI' + end + post do + ui_state = params[:ui_state] + col_id = ui_state[:currentCollectionId] + element_params = ui_state[:device_description] + device_description_ids = + DeviceDescription.for_user(current_user.id) + .for_ui_state_with_collection(element_params, CollectionsDeviceDescription, col_id) + DeviceDescription.where(id: device_description_ids).find_each do |device_description| + device_description.create_sub_device_description(current_user, col_id) + end + + {} # JS layer does not use the reply + end + end + + # return serialized device description by id + params do + requires :id, type: Integer + end + route_param :id do + get do + device_description = DeviceDescription.find(params[:id]) + error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, device_description).read? + + device_description_with_entity(device_description) + end + end + + # update a device description + params do + use :update_params + use :default_params + end + put ':id' do + device_description = DeviceDescription.find(params[:id]) + error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, device_description).update? + attributes = declared(params.except(:container), include_missing: false) + device_description = + Usecases::DeviceDescriptions::Update.new(attributes, device_description, current_user).execute + device_description.container = update_datamodel(params[:container]) if params[:container].present? + device_description_with_entity(device_description) + rescue ActiveRecord::RecordInvalid + { errors: device_description.errors.messages } + end + + # delete a device description + delete ':id' do + device_description = DeviceDescription.find(params[:id]) + + error!('401 Unauthorized', 401) unless ElementPolicy.new(current_user, device_description).destroy? + error!('Device could not be deleted', 400) unless device_description.present? && device_description.destroy + + { deleted: device_description.id } + end + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/app/api/chemotion/element_api.rb b/app/api/chemotion/element_api.rb index a5fa586371..8868fbcc57 100644 --- a/app/api/chemotion/element_api.rb +++ b/app/api/chemotion/element_api.rb @@ -38,6 +38,9 @@ class ElementAPI < Grape::API optional :cell_line, type: Hash do use :ui_state_params end + optional :device_description, type: Hash do + use :ui_state_params + end optional :selecteds, desc: 'Elements currently opened in detail tabs', type: Array do optional :type, type: String optional :id, type: Integer @@ -77,7 +80,7 @@ class ElementAPI < Grape::API desc "delete element from ui state selection." delete do deleted = { 'sample' => [] } - %w[sample reaction wellplate screen research_plan cell_line].each do |element| + %w[sample reaction wellplate screen research_plan cell_line device_description].each do |element| next unless params[element] next unless params[element][:checkedAll] || params[element][:checkedIds].present? diff --git a/app/api/chemotion/literature_api.rb b/app/api/chemotion/literature_api.rb index ebeff5daf0..6839f7f6e4 100644 --- a/app/api/chemotion/literature_api.rb +++ b/app/api/chemotion/literature_api.rb @@ -62,7 +62,7 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = desc 'Return the literature list for the given element' params do requires :element_id, type: Integer - requires :element_type, type: String, values: %w[sample reaction research_plan cell_line] + requires :element_type, type: String, values: %w[sample reaction research_plan cell_line device_description] end get do @@ -78,7 +78,7 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = desc 'create a literature entry' params do requires :element_id, type: Integer - requires :element_type, type: String, values: %w[sample reaction research_plan cell_line] + requires :element_type, type: String, values: %w[sample reaction research_plan cell_line device_description] requires :ref, type: Hash do optional :is_new, type: Boolean optional :id, types: [Integer, String] @@ -131,7 +131,7 @@ def citation_for_elements(id = params[:element_id], type = @element_klass, cat = params do requires :element_id, type: Integer - requires :element_type, type: String, values: %w[sample reaction research_plan cell_line] + requires :element_type, type: String, values: %w[sample reaction research_plan cell_line device_description] requires :id, type: Integer end diff --git a/app/api/chemotion/profile_api.rb b/app/api/chemotion/profile_api.rb index a3d905d685..06d2009187 100644 --- a/app/api/chemotion/profile_api.rb +++ b/app/api/chemotion/profile_api.rb @@ -16,6 +16,7 @@ def validate_param!(attr_name, params) end end + # rubocop: disable Metrics/ClassLength class ProfileAPI < Grape::API resource :profiles do desc 'Return the profile of the current_user' @@ -97,6 +98,7 @@ class ProfileAPI < Grape::API optional :layout_detail_sample, type: Hash, profile_layout_hash: true optional :layout_detail_wellplate, type: Hash, profile_layout_hash: true optional :layout_detail_screen, type: Hash, profile_layout_hash: true + optional :layout_detail_device_description, type: Hash, profile_layout_hash: true optional :export_selection, type: Hash do optional :sample, type: [Boolean] optional :reaction, type: [Boolean] @@ -134,6 +136,7 @@ class ProfileAPI < Grape::API 'screen' => 4, 'research_plan' => 5, 'cell_line' => -1000, + 'device_description' => -1100, } if data['layout'].nil? layout = data['layout'].select { |e| available_ements.include?(e) } @@ -252,5 +255,6 @@ class ProfileAPI < Grape::API end end end + # rubocop: enable Metrics/ClassLength end # rubocop: enable Style/MultilineIfModifier diff --git a/app/api/chemotion/text_template_api.rb b/app/api/chemotion/text_template_api.rb index 085ea5a6b1..0c5b3a62be 100644 --- a/app/api/chemotion/text_template_api.rb +++ b/app/api/chemotion/text_template_api.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module Chemotion class TextTemplateAPI < Grape::API resource :text_templates do diff --git a/app/api/entities/attachment_entity.rb b/app/api/entities/attachment_entity.rb index 7166d02dc2..b3c1ca24d1 100644 --- a/app/api/entities/attachment_entity.rb +++ b/app/api/entities/attachment_entity.rb @@ -3,7 +3,11 @@ module Entities class AttachmentEntity < ApplicationEntity expose :id, documentation: { type: 'Integer', desc: "Attachment's unique id" } - expose :filename, :identifier, :content_type, :thumb, :aasm_state, :filesize + expose :filename, :identifier, :content_type, :thumb, :aasm_state, :filesize, :thumbnail expose_timestamps + + def thumbnail + object.thumb ? Base64.encode64(object.read_thumbnail) : nil + end end end diff --git a/app/api/entities/device_description_entity.rb b/app/api/entities/device_description_entity.rb new file mode 100644 index 0000000000..9973cdae91 --- /dev/null +++ b/app/api/entities/device_description_entity.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Entities + class DeviceDescriptionEntity < ApplicationEntity + expose :id + expose :device_id + expose :name + expose :short_label + expose :device_type + expose :device_type_detail + expose :operation_mode + expose :vendor_device_name + expose :vendor_device_id + expose :serial_number + expose :vendor_company_name + expose :vendor_id + expose :description + expose :general_tags + expose :version_number + expose :version_installation_start_date + expose :version_installation_end_date + expose :version_identifier_type + expose :version_doi + expose :version_doi_url + expose :version_characterization + expose :operators + expose :university_campus + expose :institute + expose :building + expose :room + expose :infrastructure_assignment + expose :access_options + expose :access_comments + expose :size + expose :weight + expose :weight_unit + expose :application_name + expose :application_version + expose :vendor_url + expose :helpers_uploaded + expose :policies_and_user_information + expose :description_for_methods_part + expose :type + expose :changed + expose :container, using: 'Entities::ContainerEntity' + expose :attachments, using: 'Entities::AttachmentEntity' + expose :ontologies + expose :segments, using: 'Labimotion::SegmentEntity' + expose :comments, using: 'Entities::CommentEntity' + expose :comment_count + expose :tag, using: 'Entities::ElementTagEntity' + expose! :can_copy, unless: :displayed_in_list + expose! :ancestor_ids + expose :setup_descriptions + expose :maintenance_contract_available + expose :maintenance_scheduling + expose :contact_for_maintenance + expose :planned_maintenance + expose :consumables_needed_for_maintenance + expose :unexpected_maintenance + expose :measures_after_full_shut_down + expose :measures_after_short_shut_down + expose :measures_to_plan_offline_period + expose :restart_after_planned_offline_period + + expose_timestamps + + private + + def type + 'device_description' + end + + def changed + false + end + + def comment_count + object.comments.count + end + end +end diff --git a/app/api/entities/user_entity.rb b/app/api/entities/user_entity.rb index 3a15ef41b4..cf1cbc3b46 100644 --- a/app/api/entities/user_entity.rb +++ b/app/api/entities/user_entity.rb @@ -10,6 +10,7 @@ class UserEntity < Grape::Entity expose :samples_count, documentation: { type: 'Integer', desc: 'Sample count' } expose :reactions_count, documentation: { type: 'Integer', desc: 'Reactions count' } expose :cell_lines_count, documentation: { type: 'Integer', desc: 'Cellline Samples count' } + expose :device_descriptions_count, documentation: { type: 'Integer', desc: 'Device Descriptions count' } expose :type, if: ->(obj, _opts) { obj.respond_to? :type } expose :reaction_name_prefix, if: ->(obj, _opts) { obj.respond_to? :reaction_name_prefix } expose :layout, if: ->(obj, _opts) { obj.respond_to? :layout } @@ -38,6 +39,10 @@ def cell_lines_count object.counters['celllines'].to_i end + def device_descriptions_count + object.counters['device_descriptions'].to_i + end + expose :current_sign_in_at do |obj| return nil unless obj.respond_to? :current_sign_in_at diff --git a/app/api/helpers/collection_helpers.rb b/app/api/helpers/collection_helpers.rb index cd840ef6fd..d849ae9830 100644 --- a/app/api/helpers/collection_helpers.rb +++ b/app/api/helpers/collection_helpers.rb @@ -38,7 +38,8 @@ def detail_level_for_collection(id, is_sync = false) :sample_detail_level, :reaction_detail_level, :wellplate_detail_level, :screen_detail_level, :researchplan_detail_level, :element_detail_level, - :celllinesample_detail_level + :celllinesample_detail_level, + :devicedescription_detail_level )&.symbolize_keys { permission_level: 0, @@ -49,6 +50,7 @@ def detail_level_for_collection(id, is_sync = false) researchplan_detail_level: 0, element_detail_level: 0, celllinesample_detail_level: 0, + devicedescription_detail_level: 0, }.merge(dl || {}) end @@ -123,6 +125,7 @@ def set_var(c_id = params[:collection_id], is_sync = params[:is_sync]) researchplan_detail_level: 10, element_detail_level: 10, celllinesample_detail_level: 10, + devicedescription_detail_level: 10, } @dl = detail_level_for_collection(c_id, is_sync) unless @is_owned @@ -134,6 +137,7 @@ def set_var(c_id = params[:collection_id], is_sync = params[:is_sync]) @dl_rp = @dl[:researchplan_detail_level] @dl_e = @dl[:element_detail_level] @dl_cl = @dl[:celllinesample_detail_level] + @dl_dd = @dl[:devicedescription_detail_level] end end # rubocop:enable Metrics/ModuleLength, Style/OptionalBooleanParameter, Naming/MethodParameterName, Layout/LineLength diff --git a/app/assets/stylesheets/global-styles/package-mods/react-datepicker.scss b/app/assets/stylesheets/global-styles/package-mods/react-datepicker.scss new file mode 100644 index 0000000000..f6634cd560 --- /dev/null +++ b/app/assets/stylesheets/global-styles/package-mods/react-datepicker.scss @@ -0,0 +1,10 @@ +.react-datepicker-wrapper { + width: 100%; + .react-datepicker__input-container input { + width: 100%; + border-radius: 4px; + box-shadow: inset 0 2px 2px $gray-100; + border: 1px solid $gray-200; + padding: 6px 10px 5px; + } +} diff --git a/app/assets/stylesheets/global-styles/package-mods/react-select.scss b/app/assets/stylesheets/global-styles/package-mods/react-select.scss index f7ab72b564..84eb0cb879 100644 --- a/app/assets/stylesheets/global-styles/package-mods/react-select.scss +++ b/app/assets/stylesheets/global-styles/package-mods/react-select.scss @@ -82,8 +82,8 @@ } &__value-container { - @extend .flex-nowrap; @extend .text-nowrap; + @extend .gap-1; } &__value-container.chemotion-select__value-container--is-multi { @@ -96,11 +96,6 @@ @extend .m-0; } - &__value-container { - @extend .gap-1; - @extend .d-flex; - } - &__single-value .badge { @extend .d-block } @@ -159,3 +154,65 @@ @include fa-based-remove-icon; } } + +.select-in-inputgroup-text { + &__control { + @extend .d-flex; + @extend .justify-content-between; + @extend .align-items-center; + @extend .gap-1; + min-height: auto !important; + + &:hover { + border-color: $form-select-border-color; + } + + &--menu-is-open { + border-color: color("primary"); + } + + &:focus, &--is-focused { + border-color: $input-focus-border-color; + outline: 0; + @if $enable-shadows { + @include box-shadow($form-select-box-shadow, $form-select-focus-box-shadow); + } @else { + // Avoid using mixin so we can pass custom focus shadow properly + box-shadow: $form-select-focus-box-shadow; + } + } + } + + &__menu { + padding: $form-select-padding-y 0; + box-shadow: none; + z-index: 10; + } + + &__option { + padding: $form-select-padding-y $form-select-padding-x; + + &--is-selected, &--is-selected.chemotion-select__option--is-focused { + background-color: color("primary"); + } + + &--is-focused { + background-color: $input-focus-border-color; + } + } +} + +.select-in-inputgroup-text__control { + min-height: 30px !important; +} + +.select-in-inputgroup-text__menu-list { + @extend .bg-white; +} + +.select-in-inputgroup-text__option, .select-in-inputgroup-text__menu-list { + width: 160px; + white-space: normal; + text-align: left; + overflow-x: hidden; +} diff --git a/app/assets/stylesheets/global-styles/utilities.scss b/app/assets/stylesheets/global-styles/utilities.scss index 9db265ab08..802efaf513 100644 --- a/app/assets/stylesheets/global-styles/utilities.scss +++ b/app/assets/stylesheets/global-styles/utilities.scss @@ -5,4 +5,5 @@ @import "utilities/detail-card"; @import "utilities/tabs-container-with-borders"; @import "utilities/modal"; +@import "utilities/form"; @import "utilities/draggable-modal"; diff --git a/app/assets/stylesheets/global-styles/utilities/detail-card.scss b/app/assets/stylesheets/global-styles/utilities/detail-card.scss index 2fb648420e..a432929754 100644 --- a/app/assets/stylesheets/global-styles/utilities/detail-card.scss +++ b/app/assets/stylesheets/global-styles/utilities/detail-card.scss @@ -2,7 +2,7 @@ > .card-header { @extend .text-bg-primary; } - &.detail-card--unsaved .card-header { + &.detail-card--unsaved > .card-header { @extend .text-bg-info; } } diff --git a/app/assets/stylesheets/global-styles/utilities/dnd.scss b/app/assets/stylesheets/global-styles/utilities/dnd.scss index 89d810caed..80aa893dd9 100644 --- a/app/assets/stylesheets/global-styles/utilities/dnd.scss +++ b/app/assets/stylesheets/global-styles/utilities/dnd.scss @@ -7,6 +7,15 @@ @extend .text-center; } +.dnd-zone-list-item { + @extend .w-100; + @extend .border; + @extend .border-dashed; + @extend .border-3; + @extend .p-3; + @extend .text-start; +} + .dnd-zone-over { @extend .border-primary; } diff --git a/app/assets/stylesheets/global-styles/utilities/form.scss b/app/assets/stylesheets/global-styles/utilities/form.scss new file mode 100644 index 0000000000..3f756b1f4c --- /dev/null +++ b/app/assets/stylesheets/global-styles/utilities/form.scss @@ -0,0 +1,20 @@ +.multiple-row-fields { + @extend .d-flex; + @extend .justify-content-between; + @extend .align-items-end; + @extend .flex-nowrap; + @extend .gap-2; + @extend .mb-4; +} + +.multiple-row-fields.components:last-child { + @extend .mb-3; +} + +.multiple-row-fields > div { + flex: 1; +} + +.multiple-row-fields .dnd-zone + button { + @extend .my-auto; +} diff --git a/app/assets/stylesheets/global-styles/vendor.scss b/app/assets/stylesheets/global-styles/vendor.scss index 3ad5496d67..62d278a08a 100644 --- a/app/assets/stylesheets/global-styles/vendor.scss +++ b/app/assets/stylesheets/global-styles/vendor.scss @@ -18,3 +18,4 @@ @import "package-mods/react-svg-file-zoom-pan"; @import "package-mods/react-select"; @import "package-mods/ag-grid"; +@import "package-mods/react-datepicker"; diff --git a/app/assets/stylesheets/legacy/miscellaneous.scss b/app/assets/stylesheets/legacy/miscellaneous.scss index dd80a210fc..bce032fa97 100644 --- a/app/assets/stylesheets/legacy/miscellaneous.scss +++ b/app/assets/stylesheets/legacy/miscellaneous.scss @@ -104,11 +104,22 @@ $font-icons-research_plan: "\f0f6"; .icon-cell_line { font-style: inherit; } + .icon-cell_line:before { font-family: FontAwesome; content: "\f0a3"; } +.icon-device_description { + font-style: inherit; +} + +.icon-device_description:before { + font-family: FontAwesome; + content: "\f1b2"; +} + + .importChemDrawModal { width: 98%; height: 97%; diff --git a/app/javascript/src/apps/generic/Utils.js b/app/javascript/src/apps/generic/Utils.js index 195325f497..56a706a870 100644 --- a/app/javascript/src/apps/generic/Utils.js +++ b/app/javascript/src/apps/generic/Utils.js @@ -93,7 +93,7 @@ export const segmentsByKlass = name => { export const elementNames = async (all = true, generics = null) => { const elnElements = all - ? ['sample', 'reaction', 'screen', 'wellplate', 'research_plan', 'cell_line'] + ? ['sample', 'reaction', 'screen', 'wellplate', 'research_plan', 'cell_line', 'device_description'] : []; try { if (generics?.length > 0) return elnElements.concat(generics?.map((el) => el.name)); diff --git a/app/javascript/src/apps/mydb/collections/CollectionTabs.js b/app/javascript/src/apps/mydb/collections/CollectionTabs.js index c37ced4e19..6c6920dd6a 100644 --- a/app/javascript/src/apps/mydb/collections/CollectionTabs.js +++ b/app/javascript/src/apps/mydb/collections/CollectionTabs.js @@ -16,7 +16,8 @@ const elements = [ { name: 'reaction', label: 'Reaction' }, { name: 'wellplate', label: 'Wellplate' }, { name: 'screen', label: 'Screen' }, - { name: 'research_plan', label: 'Research Plan' } + { name: 'research_plan', label: 'Research Plan' }, + { name: 'device_description', label: 'Device description' } ]; export default class CollectionTabs extends React.Component { diff --git a/app/javascript/src/apps/mydb/elements/Elements.js b/app/javascript/src/apps/mydb/elements/Elements.js index 9f9550d53d..1d41d6d522 100644 --- a/app/javascript/src/apps/mydb/elements/Elements.js +++ b/app/javascript/src/apps/mydb/elements/Elements.js @@ -4,8 +4,11 @@ import { Row, Col } from 'react-bootstrap'; import ElementsList from 'src/apps/mydb/elements/list/ElementsList'; import ElementDetails from 'src/apps/mydb/elements/details/ElementDetails'; import ElementStore from 'src/stores/alt/stores/ElementStore'; +import { StoreContext } from 'src/stores/mobx/RootStore'; export default class Elements extends Component { + static contextType = StoreContext; + constructor(props) { super(props); this.state = { @@ -25,6 +28,9 @@ export default class Elements extends Component { handleOnChange(state) { const { currentElement } = state; + if (currentElement && currentElement.type == 'device_description') { + this.context.deviceDescriptions.setDeviceDescription(currentElement, true); + } this.setState({ currentElement }); } diff --git a/app/javascript/src/apps/mydb/elements/details/ElementDetails.js b/app/javascript/src/apps/mydb/elements/details/ElementDetails.js index 69335c1b05..91cd9d7d10 100644 --- a/app/javascript/src/apps/mydb/elements/details/ElementDetails.js +++ b/app/javascript/src/apps/mydb/elements/details/ElementDetails.js @@ -11,6 +11,7 @@ import React, { Component } from 'react'; import ReactionDetails from 'src/apps/mydb/elements/details/reactions/ReactionDetails'; import ReportContainer from 'src/apps/mydb/elements/details/reports/ReportContainer'; import ResearchPlanDetails from 'src/apps/mydb/elements/details/researchPlans/ResearchPlanDetails'; +import DeviceDescriptionDetails from 'src/apps/mydb/elements/details/deviceDescriptions/DeviceDescriptionDetails'; import SampleDetails from 'src/apps/mydb/elements/details/samples/SampleDetails'; import ScreenDetails from 'src/apps/mydb/elements/details/screens/ScreenDetails'; import UserStore from 'src/stores/alt/stores/UserStore'; @@ -175,6 +176,12 @@ export default class ElementDetails extends Component { toggleFullScreen={this.toggleFullScreen} /> ); + case 'device_description': + return ( + + ); case 'metadata': return ; case 'report': diff --git a/app/javascript/src/apps/mydb/elements/details/deviceDescriptions/DeviceDescriptionDetails.js b/app/javascript/src/apps/mydb/elements/details/deviceDescriptions/DeviceDescriptionDetails.js new file mode 100644 index 0000000000..f28ff2b896 --- /dev/null +++ b/app/javascript/src/apps/mydb/elements/details/deviceDescriptions/DeviceDescriptionDetails.js @@ -0,0 +1,245 @@ +import React, { useState, useEffect, useContext } from 'react'; +import { + ButtonToolbar, Button, Tabs, Tab, Tooltip, OverlayTrigger, Card +} from 'react-bootstrap'; + +import PropertiesForm from './propertiesTab/PropertiesForm'; +import DetailsForm from './detailsTab/DetailsForm'; +import AnalysesContainer from './analysesTab/AnalysesContainer'; +import AttachmentForm from './attachmentsTab/AttachmentForm'; +import MaintenanceForm from './maintenanceTab/MaintenanceForm'; + +import ElementCollectionLabels from 'src/apps/mydb/elements/labels/ElementCollectionLabels'; +import HeaderCommentSection from 'src/components/comments/HeaderCommentSection'; +import CommentSection from 'src/components/comments/CommentSection'; +import CommentActions from 'src/stores/alt/actions/CommentActions'; +import CommentModal from 'src/components/common/CommentModal'; +import { commentActivation } from 'src/utilities/CommentHelper'; +import MatrixCheck from 'src/components/common/MatrixCheck'; +import ConfirmClose from 'src/components/common/ConfirmClose'; +import PrintCodeButton from 'src/components/common/PrintCodeButton'; +import ElementDetailSortTab from 'src/apps/mydb/elements/details/ElementDetailSortTab'; +import OpenCalendarButton from 'src/components/calendar/OpenCalendarButton'; +import CopyElementModal from 'src/components/common/CopyElementModal'; +import Immutable from 'immutable'; +import { formatTimeStampsOfElement } from 'src/utilities/timezoneHelper'; + +import AttachmentFetcher from 'src/fetchers/AttachmentFetcher'; + +import { observer } from 'mobx-react'; +import { StoreContext } from 'src/stores/mobx/RootStore'; +import ElementActions from 'src/stores/alt/actions/ElementActions'; +import DetailActions from 'src/stores/alt/actions/DetailActions'; +import LoadingActions from 'src/stores/alt/actions/LoadingActions'; +import UIStore from 'src/stores/alt/stores/UIStore'; +import UserStore from 'src/stores/alt/stores/UserStore'; +import CollectionUtils from 'src/models/collection/CollectionUtils'; + +const DeviceDescriptionDetails = ({ toggleFullScreen }) => { + const deviceDescriptionsStore = useContext(StoreContext).deviceDescriptions; + let deviceDescription = deviceDescriptionsStore.device_description; + deviceDescriptionsStore.setKeyPrefix('deviceDescription'); + + const { currentCollection, isSync } = UIStore.getState(); + const { currentUser } = UserStore.getState(); + + const [activeTab, setActiveTab] = useState('properties'); // state from store + const [visibleTabs, setVisibleTabs] = useState(Immutable.List()); + + const submitLabel = deviceDescription.isNew ? 'Create' : 'Save'; + let tabContents = []; + + useEffect(() => { + if (MatrixCheck(currentUser.matrix, commentActivation) && !deviceDescription.isNew) { + CommentActions.fetchComments(deviceDescription); + } + }, []); + + const tabContentComponents = { + properties: PropertiesForm, + detail: DetailsForm, + analyses: AnalysesContainer, + attachments: AttachmentForm, + maintenance: MaintenanceForm, + }; + + const tabTitles = { + properties: 'Properties', + detail: 'Details', + analyses: 'Analyses', + attachments: 'Attachment', + maintenance: 'Maintenance', + }; + + const isReadOnly = () => { + return CollectionUtils.isReadOnly( + currentCollection, + currentUser.id, + isSync + ); + } + + const disabled = (index) => { + return deviceDescription.id.toString().length < 30 || index === 0 ? false : true; + } + + visibleTabs.forEach((key, i) => { + tabContents.push( + + { + !deviceDescription.isNew && + + } + {React.createElement(tabContentComponents[key], { + key: `${deviceDescription.id}-${key}`, + readonly: isReadOnly() + })} + + ); + }); + + const onTabPositionChanged = (visible) => { + setVisibleTabs(visible); + } + + const handleTabChange = (key) => { + setActiveTab(key); + } + + const handleSubmit = () => { + LoadingActions.start(); + if (deviceDescription.is_new) { + DetailActions.close(deviceDescription, true); + ElementActions.createDeviceDescription(deviceDescription); + } else { + ElementActions.updateDeviceDescription(deviceDescription); + } + } + + const deviceDescriptionIsValid = () => { + // TODO: validation + return true; + } + + const handleExportAnalyses = () => { + deviceDescriptionsStore.toggleAnalysisStartExport(); + AttachmentFetcher.downloadZipByDeviceDescription(deviceDescription.id) + .then(() => { deviceDescriptionsStore.toggleAnalysisStartExport(); }) + .catch((errorMessage) => { console.log(errorMessage); }); + } + + const downloadAnalysisButton = () => { + const hasNoAnalysis = deviceDescription.analyses?.length === 0 || deviceDescription.analyses?.length === undefined; + if (deviceDescription.isNew || hasNoAnalysis) { return null; } + + return ( + + ); + } + + const deviceDescriptionHeader = () => { + const titleTooltip = formatTimeStampsOfElement(deviceDescription || {}); + const defCol = currentCollection && currentCollection.is_shared === false + && currentCollection.is_locked === false && currentCollection.label !== 'All' ? currentCollection.id : null; + + return ( +
+
+ {titleTooltip}}> + + + {deviceDescription.name} + + + + +
+
+ + {!deviceDescription.isNew && + } + FullScreen} + > + + + {deviceDescription.can_copy && !deviceDescription.isNew && ( + + )} + {deviceDescription.isEdited && ( + Save device description} + > + + + )} + +
+
+ ); + } + + return ( + + + {deviceDescriptionHeader()} + + + +
+ handleTabChange(key)} + id="deviceDescriptionDetailsTab" + unmountOnExit + > + {tabContents} + +
+ +
+ + + + + {downloadAnalysisButton()} + + +
+ ); +} + +export default observer(DeviceDescriptionDetails); diff --git a/app/javascript/src/apps/mydb/elements/details/deviceDescriptions/FormFields.js b/app/javascript/src/apps/mydb/elements/details/deviceDescriptions/FormFields.js new file mode 100644 index 0000000000..09ef70c35a --- /dev/null +++ b/app/javascript/src/apps/mydb/elements/details/deviceDescriptions/FormFields.js @@ -0,0 +1,751 @@ +import React from 'react'; +import { + InputGroup, OverlayTrigger, Tooltip, Button, Form, +} from 'react-bootstrap'; +import { Select } from 'src/components/common/Select'; +import DatePicker from 'react-datepicker'; +import { useDrop } from 'react-dnd'; +import { DragDropItemTypes } from 'src/utilities/DndConst'; +import ChevronIcon from 'src/components/common/ChevronIcon'; +import { handleFloatNumbers } from 'src/utilities/UnitsConversion'; +import moment from 'moment'; +import { v4 as uuid } from 'uuid'; + +import { elementShowOrNew } from 'src/utilities/routesUtils'; +import UIStore from 'src/stores/alt/stores/UIStore'; + +const valueByType = (type, event) => { + let value = []; + switch (type) { + case 'text': + case 'textarea': + case 'textWithAddOn': + case 'system-defined': + case 'formula-field': + case 'subGroupWithAddOn': + case 'numeric': + return event.target.value; + case 'checkbox': + return event.target.checked; + case 'select': + return event.value ? event.value : event.label; + case 'multiselect': + event.forEach((element) => { + element?.value ? value.push(element.value) : value.push(element) + }); + return value; + case 'datetime': + return moment(event, 'YYYY-MM-DD HH:mm:ss').toISOString(); + case 'date': + return moment(event, 'YYYY-MM-DD').toISOString(); + case 'time': + return moment(event, 'HH:mm').toISOString(); + default: + return event; + } +} + +const fieldByType = (option, field, fields, element, store, info) => { + switch (option.type) { + case 'text': + fields.push(textInput(element, store, field, option.label, info)); + break; + case 'textarea': + fields.push(textareaInput(element, store, field, option.label, option.rows, info)); + break; + case 'checkbox': + fields.push(checkboxInput(element, option.label, field, store)); + break; + case 'select': + fields.push(selectInput(element, store, field, option.label, option.options, info)); + break; + case 'numeric': + fields.push(numericInput(element, store, field, option.label, option.type, info)); + break; + case 'time': + fields.push(timePickerInput(element, store, field, option.label, info)); + break; + case 'date': + fields.push(datePickerInput(element, store, field, option.label, info)) + break; + } + return fields; +} + +const handleFieldChanged = (store, field, type, element_type) => (event) => { + let value = event === null ? '' : valueByType(type, event); + + if (element_type == 'device_description') { + store.changeDeviceDescription(field, value); + } +} + +const weightConversion = (value, multiplier) => value * multiplier; + +const weightConversionMap = { + t: { convertedUnit: 'kg', conversionFactor: 1000 }, + kg: { convertedUnit: 'g', conversionFactor: 1000 }, + g: { convertedUnit: 't', conversionFactor: 0.000001 }, +}; + +const convertByUnit = (valueToFormat, currentUnit) => { + const { convertedUnit, conversionFactor } = weightConversionMap[currentUnit]; + const decimalPlaces = 7; + const formattedValue = weightConversion(valueToFormat, conversionFactor); + const convertedValue = handleFloatNumbers(formattedValue, decimalPlaces); + return [convertedValue, convertedUnit]; +}; + +const changeWeightUnit = (element, store, field, field_unit, element_type) => { + const oldValue = element[field]; + const oldUnitValue = element[field_unit] || 'kg'; + const [newValue, newUnitValue] = convertByUnit(oldValue, oldUnitValue); + + if (element_type == 'device_description') { + if (newValue !== 0) { + store.changeDeviceDescription(field, newValue); + } + store.changeDeviceDescription(field_unit, newUnitValue); + } +} + +const toggleContent = (store, content) => { + store.toggleContent(content); +} + +const allowedAnnotationFileTypes = ['png', 'jpg', 'bmp', 'tif', 'svg', 'jpeg', 'tiff']; + +const annotationButton = (store, attachment) => { + if (!attachment || !attachment.filename) { return null; } + + const extension = attachment.filename.split('.').pop(); + const isAllowedFileType = allowedAnnotationFileTypes.includes(extension); + const isActive = isAllowedFileType && !attachment.isNew; + const className = !isAllowedFileType ? 'attachment-gray-button' : ''; + const tooltipText = isActive + ? 'Annotate image' + : 'Cannot annotate - invalid file type or the image is new'; + + return ( + {tooltipText}} + > + +