diff --git a/VERSION b/VERSION index 450a687b2..6e66c4c20 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.28.1 +1.28.1.3 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..d50df5ad9 --- /dev/null +++ b/app/assets/javascripts/sitewide/iframe_modal.js @@ -0,0 +1,31 @@ +/* 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'); + + 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/shared/assets.scss b/app/assets/stylesheets/shared/assets.scss index fc6a4a892..6336b2f14 100644 --- a/app/assets/stylesheets/shared/assets.scss +++ b/app/assets/stylesheets/shared/assets.scss @@ -477,3 +477,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 00c27c3ab..e081a9d2c 100644 --- a/app/assets/stylesheets/shared_styles/elements/input_fields.scss +++ b/app/assets/stylesheets/shared_styles/elements/input_fields.scss @@ -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 891c63fe6..c4acd18ab 100644 --- a/app/assets/stylesheets/tailwind/inputs.css +++ b/app/assets/stylesheets/tailwind/inputs.css @@ -32,6 +32,16 @@ @apply border-sn-science-blue 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 text-sn-black; } diff --git a/app/controllers/access_permissions/projects_controller.rb b/app/controllers/access_permissions/projects_controller.rb index 4d84406d2..5948429ef 100644 --- a/app/controllers/access_permissions/projects_controller.rb +++ b/app/controllers/access_permissions/projects_controller.rb @@ -99,21 +99,11 @@ module AccessPermissions raise ActiveRecord::RecordInvalid end - if @project.visible? - user_assignment.update!( - user_role: @project.default_public_user_role, - assigned: :automatically - ) - else - user_assignment.destroy! - end - propagate_job(user_assignment, destroy: true) log_activity(:unassign_user_from_project, { user_target: user_assignment.user.id, role: user_assignment.user_role.name }) - render json: { flash: t('access_permissions.destroy.success', member_name: escape_input(user.full_name)) }, - status: :ok + render json: { flash: t('access_permissions.destroy.success', member_name: escape_input(user.full_name)) } rescue ActiveRecord::RecordInvalid render json: { flash: t('access_permissions.destroy.failure') }, status: :unprocessable_entity diff --git a/app/controllers/at_who_controller.rb b/app/controllers/at_who_controller.rb index 805d0bc7b..f6a801d11 100644 --- a/app/controllers/at_who_controller.rb +++ b/app/controllers/at_who_controller.rb @@ -29,13 +29,18 @@ class AtWhoController < ApplicationController else Repository.active.accessible_by_teams(@team).first end + + items = [] + repository_id = nil + if repository && can_read_repository?(repository) + assignable_my_module = + if params[:assignable_my_module_id].present? + MyModule.viewable_by_user(current_user, @team).find_by(id: params[:assignable_my_module_id]) + end items = SmartAnnotation.new(current_user, current_team, @query) - .repository_rows(repository, params[:assignable_my_module_id]) + .repository_rows(repository, assignable_my_module&.id) repository_id = repository.id - else - items = [] - repository_id = nil end render json: { res: [ diff --git a/app/controllers/gene_sequence_assets_controller.rb b/app/controllers/gene_sequence_assets_controller.rb new file mode 100644 index 000000000..7ce3e313f --- /dev/null +++ b/app/controllers/gene_sequence_assets_controller.rb @@ -0,0 +1,176 @@ +# 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_result?(@parent) + 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_result?(@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/protocols_controller.rb b/app/controllers/protocols_controller.rb index 8a84255e5..7a46b538f 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/controllers/users/settings/account/connected_accounts_controller.rb b/app/controllers/users/settings/account/connected_accounts_controller.rb index 8f3c981de..d30a2579d 100644 --- a/app/controllers/users/settings/account/connected_accounts_controller.rb +++ b/app/controllers/users/settings/account/connected_accounts_controller.rb @@ -9,20 +9,15 @@ module Users end def destroy - settings = ApplicationSettings.instance - if settings.values['azure_ad_apps']&.find { |v| v['provider_name'] == params[:provider] } - provider = params[:provider] - else - flash[:error] = t('users.settings.account.connected_accounts.errors.not_found') + user_identity = current_user.user_identities.find_by(provider: params[:provider]) + if user_identity.blank? + flash.now[:error] = t('users.settings.account.connected_accounts.errors.not_found') return end - ActiveRecord::Base.transaction do - __send__("#{provider}_pre_destroy".to_sym) if respond_to?("#{provider}_pre_destroy".to_sym, true) - current_user.user_identities.where(provider: provider).take&.destroy! - end - flash[:success] = t('users.settings.account.connected_accounts.unlink_success') + user_identity.destroy! + flash.now[:success] = t('users.settings.account.connected_accounts.unlink_success') rescue StandardError - flash[:error] ||= t('users.settings.account.connected_accounts.errors.generic') + flash.now[:error] ||= t('users.settings.account.connected_accounts.errors.generic') ensure @linked_accounts = current_user.user_identities.pluck(:provider) render :index diff --git a/app/helpers/file_icons_helper.rb b/app/helpers/file_icons_helper.rb index d5219e19d..adba2aab3 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..ae52453ea --- /dev/null +++ b/app/javascript/vue/ove/OpenVectorEditor.vue @@ -0,0 +1,154 @@ + + + diff --git a/app/javascript/vue/protocol/step.vue b/app/javascript/vue/protocol/step.vue index ecee1aaa6..3a04ff921 100644 --- a/app/javascript/vue/protocol/step.vue +++ b/app/javascript/vue/protocol/step.vue @@ -96,6 +96,9 @@
  • {{ i18n.t('assets.create_wopi_file.button_text') }}
  • +
  • + {{ i18n.t('open_vector_editor.new_sequence_file') }} +
  • - + + + + diff --git a/app/javascript/vue/protocol/step_attachments/thumbnail.vue b/app/javascript/vue/protocol/step_attachments/thumbnail.vue new file mode 100644 index 000000000..0e85c05a5 --- /dev/null +++ b/app/javascript/vue/protocol/step_attachments/thumbnail.vue @@ -0,0 +1,64 @@ + + + diff --git a/app/javascript/vue/protocol_import/file_import_modal.vue b/app/javascript/vue/protocol_import/file_import_modal.vue index 02f761500..54f87c02f 100644 --- a/app/javascript/vue/protocol_import/file_import_modal.vue +++ b/app/javascript/vue/protocol_import/file_import_modal.vue @@ -9,10 +9,6 @@
  • {{ i18n.t('assets.create_wopi_file.button_text') }}
  • +
  • + {{ i18n.t('open_vector_editor.new_sequence_file') }} +
  • { + reader.onloadend = () => { + resolve(reader.result); + }; + }); +} diff --git a/app/javascript/vue/shared/content/attachments.vue b/app/javascript/vue/shared/content/attachments.vue index 1d4eee458..29f3edaa1 100644 --- a/app/javascript/vue/shared/content/attachments.vue +++ b/app/javascript/vue/shared/content/attachments.vue @@ -1,6 +1,6 @@