diff --git a/VERSION b/VERSION index 6e66c4c20..bf4df28ef 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.28.1.3 +1.28.2 diff --git a/app/assets/images/icon_small/sequence-editor.svg b/app/assets/images/icon_small/sequence-editor.svg new file mode 100644 index 000000000..b528c6c3f --- /dev/null +++ b/app/assets/images/icon_small/sequence-editor.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/assets/javascripts/i18n_bundle.js b/app/assets/javascripts/i18n_bundle.js new file mode 100644 index 000000000..04bbf3cf2 --- /dev/null +++ b/app/assets/javascripts/i18n_bundle.js @@ -0,0 +1,2 @@ +//= require i18n.js +//= require i18n/translations diff --git a/app/assets/javascripts/protocols/import_export/import.js b/app/assets/javascripts/protocols/import_export/import.js index 000031aa4..d1e98c722 100644 --- a/app/assets/javascripts/protocols/import_export/import.js +++ b/app/assets/javascripts/protocols/import_export/import.js @@ -58,12 +58,41 @@ function importProtocolFromFile( } function getAssetBytes(folder, stepGuid, fileRef) { - var stepPath = stepGuid ? stepGuid + '/' : ''; - var filePath = folder + stepPath + fileRef; - var assetBytes = zipFiles.files[cleanFilePath(filePath)].asBinary(); + const stepPath = stepGuid ? stepGuid + '/' : ''; + const filePath = folder + stepPath + fileRef; + const assetBytes = zipFiles.files[cleanFilePath(filePath)].asBinary(); return window.btoa(assetBytes); } + function getAssetPreview(folder, stepGuid, fileRef, fileName, fileType) { + if ($.inArray(fileType, ['image/png', 'image/jpeg', 'image/gif', 'image/bmp']) > 0) { + return { + fileName: fileName, + fileType: fileType, + bytes: getAssetBytes(folder, stepGuid, fileRef) + }; + } else { + const stepPath = stepGuid ? folder + stepGuid + '/' : folder; + let baseName; + baseName = fileRef.split('.'); + baseName.pop(); + baseName.join('.'); + let previewFileRef = zipFiles.file(new RegExp(stepPath + 'previews/' + baseName)); + if (previewFileRef.length > 0) { + const previewFileExt = previewFileRef[0].name.split('.').at(-1); + let previewFileName = fileName.split('.'); + previewFileName.splice(-1, 1, previewFileExt); + previewFileName.join('.'); + return { + fileName: previewFileName, + fileType: `image/${previewFileExt}`, + bytes: window.btoa(previewFileRef[0].asBinary()) + }; + } + } + return null; + } + /* Template functions */ function newPreviewElement(name, values) { @@ -82,14 +111,14 @@ function importProtocolFromFile( } function newAssetElement(folder, stepGuid, fileRef, fileName, fileType) { - var html = '
  • '; - var assetBytes; - if ($.inArray(fileType, ['image/png', 'image/jpeg', 'image/gif', 'image/bmp']) > 0) { - assetBytes = getAssetBytes(folder, stepGuid, fileRef); + let html = '
  • '; + let assetPreview = getAssetPreview(folder, stepGuid, fileRef, fileName, fileType); - html += ''; + if (assetPreview) { + html += ''; html += '
    '; } + html += '' + fileName + ''; html += '
  • '; return $.parseHTML(html); @@ -708,6 +737,7 @@ function importProtocolFromFile( var assetId = $(this).attr('id'); var fileRef = $(this).attr('fileRef'); var fileName = $(this).children('fileName').text(); + stepAssetJson.id = assetId; stepAssetJson.fileName = fileName; stepAssetJson.fileType = $(this).children('fileType').text(); @@ -723,6 +753,14 @@ function importProtocolFromFile( fileRef ); + stepAssetJson.preview_image = getAssetPreview( + protocolFolders[index], + stepGuid, + fileRef, + fileName, + null + ); + stepAssetsJson.push(stepAssetJson); }); stepJson.assets = stepAssetsJson; diff --git a/app/assets/javascripts/sitewide/active_storage_previews.js b/app/assets/javascripts/sitewide/active_storage_previews.js index 32697ae23..7596bf168 100644 --- a/app/assets/javascripts/sitewide/active_storage_previews.js +++ b/app/assets/javascripts/sitewide/active_storage_previews.js @@ -17,6 +17,8 @@ var ActiveStoragePreviews = (function() { if (img.retryCount >= RETRY_COUNT) return; + $(img).css('opacity', 0); + if (!$(img).parent().hasClass('processing')) $(img).parent().addClass('processing'); setTimeout(() => { diff --git a/app/assets/javascripts/sitewide/iframe_modal.js b/app/assets/javascripts/sitewide/iframe_modal.js new file mode 100644 index 000000000..70631dcb4 --- /dev/null +++ b/app/assets/javascripts/sitewide/iframe_modal.js @@ -0,0 +1,35 @@ +/* global iFrameModal */ +// General-purpose iframe modal. For closing the modal, you need will to take care of triggering +// the 'hide' event on the modal itself, example from inside the iframe: +// parent.document.getElementById('iFrameModal').dispatchEvent(new Event('hide')); + +$(document).on('turbolinks:load', function() { + window.iFrameModal = document.getElementById('iFrameModal'); + let iFrameModalFrame = document.getElementById('iFrameModalFrame'); + + // Block from running when accessing page without defined iframe modal + // (sign in, reset password, accept invitation, 2fa) + if (!iFrameModalFrame || !iFrameModal) return; + + window.showIFrameModal = (url) => { + iFrameModalFrame.setAttribute('src', url); + iFrameModal.classList.remove('hidden'); + iFrameModal.dispatchEvent(new Event('shown')); + }; + + iFrameModal.addEventListener('hide', () => { + iFrameModal.classList.add('hidden'); + iFrameModalFrame.removeAttribute('src'); + iFrameModal.dispatchEvent(new Event('hidden')); + }); + + iFrameModal.addEventListener('shown', () => { + document.body.classList.add('overflow-hidden'); + document.body.classList.remove('overflow-auto'); + }); + + iFrameModal.addEventListener('hidden', () => { + document.body.classList.remove('overflow-hidden'); + document.body.classList.add('overflow-auto'); + }); +}); diff --git a/app/assets/javascripts/sitewide/marvinjs_editor.js b/app/assets/javascripts/sitewide/marvinjs_editor.js index 60fa514d4..38021f77f 100644 --- a/app/assets/javascripts/sitewide/marvinjs_editor.js +++ b/app/assets/javascripts/sitewide/marvinjs_editor.js @@ -303,6 +303,12 @@ $(document).on('click', '.marvinjs-edit-button', function() { }); }); +$(document).on('click', '.gene-sequence-edit-button', function() { + var editButton = $(this); + $('#filePreviewModal').modal('hide'); + window.showIFrameModal(editButton.data('sequence-edit-url')); +}); + $(document).on('turbolinks:load', function() { MarvinJsEditor = MarvinJsEditorApi(); if (MarvinJsEditor.enabled()) { diff --git a/app/assets/stylesheets/reports.scss b/app/assets/stylesheets/reports.scss index bf1d9604b..3c4c7d1eb 100644 --- a/app/assets/stylesheets/reports.scss +++ b/app/assets/stylesheets/reports.scss @@ -67,6 +67,8 @@ label { * Global fix for handsontable */ .hot-table-container { + display: flex; + overflow: auto; .ht_master .wtHolder { height: auto !important; width: auto !important; diff --git a/app/assets/stylesheets/reports_print.scss b/app/assets/stylesheets/reports_print.scss index 18ac71a92..46f7f3fbb 100644 --- a/app/assets/stylesheets/reports_print.scss +++ b/app/assets/stylesheets/reports_print.scss @@ -29,6 +29,8 @@ div.print-report { } .hot-table-container { + display: flex; + overflow: auto; .ht_master .wtHolder { overflow: hidden !important; diff --git a/app/assets/stylesheets/shared/assets.scss b/app/assets/stylesheets/shared/assets.scss index 9b212b738..af600921a 100644 --- a/app/assets/stylesheets/shared/assets.scss +++ b/app/assets/stylesheets/shared/assets.scss @@ -445,3 +445,15 @@ padding: 4px 8px; white-space: nowrap; } + +.sn-file-ove { + height: 1.5rem; + width: 1.5rem; + + &::before { + content: url("icon_small/sequence-editor.svg"); + display: inline-block; + margin: auto; + width: 100%; + } +} diff --git a/app/assets/stylesheets/shared/file_preview.scss b/app/assets/stylesheets/shared/file_preview.scss index 145147c91..dbf903698 100644 --- a/app/assets/stylesheets/shared/file_preview.scss +++ b/app/assets/stylesheets/shared/file_preview.scss @@ -26,11 +26,22 @@ .file-preview-container { align-items: center; + background-color: var(--sn-color-white); display: flex; - height: 100%; + height: calc(100% - 4rem); justify-content: center; + margin: 2rem; text-align: center; - width: 100%; + width: calc(100% - 4rem); + + .asset-image { + background-color: var(--sn-white); + } + + .gene-sequence-asset { + height: 500px; + width: 500px; + } &.processing { background-image: url("/images/medium/loading_white.svg"); diff --git a/app/assets/stylesheets/shared_styles/elements/input_fields.scss b/app/assets/stylesheets/shared_styles/elements/input_fields.scss index bdb53169e..1a1035a1b 100644 --- a/app/assets/stylesheets/shared_styles/elements/input_fields.scss +++ b/app/assets/stylesheets/shared_styles/elements/input_fields.scss @@ -15,7 +15,7 @@ .sci-input-field { @include font-button; animation-timing-function: $timing-function-sharp; - border: $border-secondary; + border: 1px solid var(--sn-light-grey); border-radius: $border-radius-default !important; box-shadow: none; height: 36px; @@ -24,12 +24,18 @@ transition: .3s; width: 100%; + &:hover { + border: 1px solid var(--sn-science-blue-hover); + } + &:focus { border: $border-focus; } &:disabled { - background: transparent; + background-color: var(--sn-super-light-grey); + color: var(--sn-light-grey); + border: 1px solid var(--sn-light-grey); } &::placeholder { diff --git a/app/assets/stylesheets/sn_icon_font.css b/app/assets/stylesheets/sn_icon_font.css new file mode 100644 index 000000000..3c023c6f9 --- /dev/null +++ b/app/assets/stylesheets/sn_icon_font.css @@ -0,0 +1,4 @@ +/* +*= require sn-icon-font +*= require sn-inter-font +*/ diff --git a/app/assets/stylesheets/tailwind/inputs.css b/app/assets/stylesheets/tailwind/inputs.css index f64561fa8..5018e7216 100644 --- a/app/assets/stylesheets/tailwind/inputs.css +++ b/app/assets/stylesheets/tailwind/inputs.css @@ -29,6 +29,16 @@ box-shadow: none; } + .sci-input-container-v2 input:hover { + border-color: var(--sn-science-blue-hover); + } + + .sci-input-container-v2 input:disabled { + background-color: var(--sn-super-light-grey); + color: var(--sn-light-grey); + border: 1px solid var(--sn-light-grey); + } + .sci-input-container-v2 .sn-icon { @apply m-2; color: var(--sn-black) diff --git a/app/controllers/gene_sequence_assets_controller.rb b/app/controllers/gene_sequence_assets_controller.rb new file mode 100644 index 000000000..4d438317f --- /dev/null +++ b/app/controllers/gene_sequence_assets_controller.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +class GeneSequenceAssetsController < ApplicationController + include ActiveStorage::SetCurrent + + skip_before_action :verify_authenticity_token + + before_action :check_open_vector_service_enabled, except: %i(new edit) + before_action :load_vars, except: %i(new create) + before_action :load_create_vars, only: %i(new create) + + before_action :check_read_permission + before_action :check_manage_permission, only: %i(new update create) + + def new + render :edit, layout: false + end + + def edit + @file_url = rails_representation_url(@asset.file) + @file_name = @asset.render_file_name + log_activity('sequence_asset_edit_started') + render :edit, layout: false + end + + def create + save_asset! + log_activity('sequence_asset_added') + head :ok + end + + def update + save_asset! + log_activity('sequence_asset_edit_finished') + head :ok + end + + def destroy + log_activity('sequence_asset_deleted') + head :ok + end + + private + + def save_asset! + ActiveRecord::Base.transaction do + ensure_asset! + + @asset.file.purge + @asset.preview_image.purge + + @asset.file.attach( + io: StringIO.new(params[:sequence_data].to_json), + filename: "#{params[:sequence_name]}.json" + ) + + @asset.preview_image.attach( + io: StringIO.new(Base64.decode64(params[:base64_image].split(',').last)), + filename: "#{params[:sequence_name]}.png" + ) + + file = @asset.file + + file.blob.metadata['asset_type'] = 'gene_sequence' + file.blob.metadata['name'] = params[:sequence_name] + file.save! + @asset.view_mode ||= @parent.assets_view_mode + @asset.save! + end + end + + def ensure_asset! + return if @asset + return unless @parent + + @asset = @parent.assets.create!(last_modified_by: current_user, team: current_team) + end + + def load_vars + @ove_enabled = OpenVectorEditorService.enabled? + @asset = current_team.assets.find_by(id: params[:id]) + return render_404 unless @asset + + @parent ||= @asset.step + @parent ||= @asset.result + + case @parent + when Step + @protocol = @parent.protocol + when Result + @my_module = @parent.my_module + end + end + + def load_create_vars + @ove_enabled = OpenVectorEditorService.enabled? + @parent = case params[:parent_type] + when 'Step' + Step.find_by(id: params[:parent_id]) + when 'Result' + Result.find_by(id: params[:parent_id]) + end + + case @parent + when Step + @protocol = @parent.protocol + when Result + @result = @parent + end + end + + def check_read_permission + case @parent + when Step + return render_403 unless can_read_protocol_in_module?(@protocol) || + can_read_protocol_in_repository?(@protocol) + when Result + return render_403 unless can_read_my_module?(@my_module) + else + render_403 + end + end + + def check_manage_permission + render_403 unless asset_managable? + end + + def check_open_vector_service_enabled + render_403 unless OpenVectorEditorService.enabled? + end + + helper_method :asset_managable? + def asset_managable? + case @parent + when Step + can_manage_step?(@parent) + when Result + can_manage_my_module?(@parent) + else + false + end + end + + def log_activity(type_of, project = nil, message_items = {}) + return unless @parent.is_a?(Step) + + my_module = @parent.my_module + default_items = { + protocol: @parent.protocol.id, + step: @parent.id, + asset_name: { id: @asset.id, value_for: 'file_name' }, + step_position: { id: @parent.id, value_for: 'position_plus_one' } + } + + if my_module + project = my_module.project + default_items[:my_module] = my_module.id + type_of = "task_#{type_of}".to_sym + else + type_of = "protocol_#{type_of}".to_sym + end + + message_items = default_items.merge(message_items) + + Activities::CreateActivityService.call( + activity_type: type_of, + owner: current_user, + team: @parent.protocol.team, + subject: @parent.protocol, + message_items: message_items, + project: project + ) + end +end diff --git a/app/controllers/global_activities_controller.rb b/app/controllers/global_activities_controller.rb index 6e29f7375..4d05101c1 100644 --- a/app/controllers/global_activities_controller.rb +++ b/app/controllers/global_activities_controller.rb @@ -65,13 +65,17 @@ class GlobalActivitiesController < ApplicationController end def team_filter - render json: current_user.teams.ordered.global_activity_filter(activity_filters, params[:query]) + teams = current_user.teams.ordered.global_activity_filter(activity_filters, params[:query]) + render json: teams.select(:id, :name) + .map { |i| { value: i[:id], label: escape_input(i[:name]) } } end def user_filter filter = activity_filters filter = { subjects: { MyModule: [params[:my_module_id].to_i] } } if params[:my_module_id] - render json: current_user.global_activity_filter(filter, params[:query]) + users = current_user.global_activity_filter(filter, params[:query]) + render json: users.select(:full_name, :id) + .map { |i| { label: escape_input(i[:full_name]), value: i[:id] } } end def project_filter diff --git a/app/controllers/protocols_controller.rb b/app/controllers/protocols_controller.rb index 2019bce38..ce161bed7 100644 --- a/app/controllers/protocols_controller.rb +++ b/app/controllers/protocols_controller.rb @@ -697,6 +697,12 @@ class ProtocolsController < ApplicationController asset_file_name = asset_guid.to_s + File.extname(asset.file_name).to_s ostream.put_next_entry("#{step_dir}/#{asset_file_name}") ostream.print(asset.file.download) + + next unless asset.preview_image.attached? + + asset_preview_image_name = asset_guid.to_s + File.extname(asset.preview_image_file_name).to_s + ostream.put_next_entry("#{step_dir}/previews/#{asset_preview_image_name}") + ostream.print(asset.preview_image.download) end end ostream = step.tiny_mce_assets.save_to_eln(ostream, step_dir) diff --git a/app/helpers/file_icons_helper.rb b/app/helpers/file_icons_helper.rb index 9b03edbfb..cab2503b5 100644 --- a/app/helpers/file_icons_helper.rb +++ b/app/helpers/file_icons_helper.rb @@ -40,6 +40,8 @@ module FileIconsHelper image_link = 'icon_small/pptx_file.svg' elsif asset.file.attached? && asset.file.metadata['asset_type'] == 'marvinjs' image_link = 'icon_small/marvinjs_file.svg' + elsif asset.file.attached? && asset.file.metadata['asset_type'] == 'gene_sequence' + image_link = 'icon_small/sequence-editor.svg' end # Now check for custom mappings or possible overrides diff --git a/app/javascript/packs/open_vector_editor.js b/app/javascript/packs/open_vector_editor.js new file mode 100644 index 000000000..af3675194 --- /dev/null +++ b/app/javascript/packs/open_vector_editor.js @@ -0,0 +1,2 @@ +import '@teselagen/ove'; +import '@teselagen/ove/style.css'; diff --git a/app/javascript/packs/vue/open_vector_editor.js b/app/javascript/packs/vue/open_vector_editor.js new file mode 100644 index 000000000..c8ca74ac9 --- /dev/null +++ b/app/javascript/packs/vue/open_vector_editor.js @@ -0,0 +1,11 @@ +import TurbolinksAdapter from 'vue-turbolinks'; +import Vue from 'vue/dist/vue.esm'; +import OpenVectorEditor from '../../vue/ove/OpenVectorEditor.vue'; + +Vue.use(TurbolinksAdapter); +Vue.prototype.i18n = window.I18n; + +new Vue({ + el: '#open-vector-editor', + components: { OpenVectorEditor } +}); diff --git a/app/javascript/vue/ove/OpenVectorEditor.vue b/app/javascript/vue/ove/OpenVectorEditor.vue new file mode 100644 index 000000000..066b6f58f --- /dev/null +++ b/app/javascript/vue/ove/OpenVectorEditor.vue @@ -0,0 +1,154 @@ + + + diff --git a/app/javascript/vue/protocol/attachments.vue b/app/javascript/vue/protocol/attachments.vue index ca24c8ac1..9ac6afba3 100644 --- a/app/javascript/vue/protocol/attachments.vue +++ b/app/javascript/vue/protocol/attachments.vue @@ -1,5 +1,5 @@