diff --git a/Gemfile b/Gemfile index d27c2746ae..75c25ee378 100644 --- a/Gemfile +++ b/Gemfile @@ -59,7 +59,7 @@ gem 'i18n-js', '~> 3.6' # Localization in javascript files gem 'jbuilder' # JSON structures via a Builder-style DSL gem 'logging', '~> 2.0.0' gem 'nested_form_fields' -gem 'nokogiri', '~> 1.16.2' # HTML/XML parser +gem 'nokogiri', '~> 1.16.5' # HTML/XML parser gem 'noticed' gem 'rails_autolink', '~> 1.1', '>= 1.1.6' gem 'rgl' # Graph framework for project diagram calculations diff --git a/Gemfile.lock b/Gemfile.lock index e998b8757b..bfa66cc57c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -433,7 +433,7 @@ GEM mime-types-data (3.2023.0218.1) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.5) + mini_portile2 (2.8.6) minitest (5.22.2) msgpack (1.7.1) multi_json (1.15.0) @@ -456,12 +456,12 @@ GEM net-protocol newrelic_rpm (9.2.2) nio4r (2.7.0) - nokogiri (1.16.2) + nokogiri (1.16.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.16.2-arm64-darwin) + nokogiri (1.16.5-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.2-x86_64-linux) + nokogiri (1.16.5-x86_64-linux) racc (~> 1.4) noticed (1.6.3) http (>= 4.0.0) @@ -609,7 +609,8 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.5) + rexml (3.2.8) + strscan (>= 3.0.9) rgl (0.6.3) pairing_heap (>= 0.3.0) rexml (~> 3.2, >= 3.2.4) @@ -701,6 +702,7 @@ GEM activesupport (>= 5.2) sprockets (>= 3.0.0) stream (0.5.5) + strscan (3.1.0) swd (2.0.2) activesupport (>= 3) attr_required (>= 0.0.5) @@ -731,13 +733,13 @@ GEM unf_ext (0.0.8.2) unicode-display_width (2.4.2) uniform_notifier (1.16.0) + uri (0.13.0) validate_email (0.1.6) activemodel (>= 3.0) mail (>= 2.2.5) validate_url (1.0.15) activemodel (>= 3.0.0) public_suffix - uri (0.13.0) version_gem (1.1.3) view_component (3.9.0) activesupport (>= 5.2.0, < 8.0) @@ -827,7 +829,7 @@ DEPENDENCIES logging (~> 2.0.0) nested_form_fields newrelic_rpm - nokogiri (~> 1.16.2) + nokogiri (~> 1.16.5) noticed omniauth (~> 2.1) omniauth-azure-activedirectory-v2 diff --git a/VERSION b/VERSION index 7aa332e416..2b17ffd504 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.33.0 +1.34.0 diff --git a/app/assets/javascripts/experiments/show.js b/app/assets/javascripts/experiments/show.js index cd7bdc0e24..56f1527208 100644 --- a/app/assets/javascripts/experiments/show.js +++ b/app/assets/javascripts/experiments/show.js @@ -23,6 +23,23 @@ } }); }); + + $('#experiment-canvas').on('shown.bs.modal', () => { + // disable the submit button by default + $('#new-modal-submit-btn').prop('disabled', true); + + // listen for input event on the my_module_name input field + $(`${newMyModuleModal} #my_module_name`).on('input', function () { + if ($(this).val().trim().length > 1) { + // enable the submit button if the input field is populated + $('#new-modal-submit-btn').prop('disabled', false); + } else { + // otherwise, disable it + $('#new-modal-submit-btn').prop('disabled', true); + } + }); + }); + // Modal's submit handler function $(experimentWrapper) .on('ajax:success', newMyModuleModal, function() { diff --git a/app/assets/javascripts/my_modules.js b/app/assets/javascripts/my_modules.js index bdb85372ff..586ca8b7d6 100644 --- a/app/assets/javascripts/my_modules.js +++ b/app/assets/javascripts/my_modules.js @@ -112,8 +112,8 @@ }, optionLabel: (data) => { if (data.value > 0) { - return ` - ${data.label}`; + return `${data.label}`; } return ` ${data.label + ' '} diff --git a/app/assets/javascripts/projects/canvas.js.erb b/app/assets/javascripts/projects/canvas.js.erb index 8fcb7a7d7b..d929225f02 100644 --- a/app/assets/javascripts/projects/canvas.js.erb +++ b/app/assets/javascripts/projects/canvas.js.erb @@ -570,13 +570,20 @@ function handleAnchorClick(event) { } }; -// listen to clicks on links in navigator and leftMenuContainer -$(document).ready(function() { - const navigatorEl = $('.sci--layout-navigation-navigator'); - const leftMenuContainerEl = $('.sci--layout--left-menu-container'); +$(document).on('turbolinks:before-visit.canvas_edit', (e) => { + const alertText = $("#update-canvas").attr("data-unsaved-work-text"); - navigatorEl.on('click', 'a', handleAnchorClick); - leftMenuContainerEl.on('click', 'a', handleAnchorClick); + if (alertText) { + // eslint-disable-next-line no-alert + if (confirm(alertText)) { + $(document).off('turbolinks:before-visit.canvas_edit'); + return true; + } + e.preventDefault(); + return false; + } + $(document).off('turbolinks:before-visit.canvas_edit'); + return true; }); function bindEditModeCloseWindow() { diff --git a/app/assets/javascripts/repositories/renderers/view_renderers.js b/app/assets/javascripts/repositories/renderers/view_renderers.js index fd928fb26c..6d205771ba 100644 --- a/app/assets/javascripts/repositories/renderers/view_renderers.js +++ b/app/assets/javascripts/repositories/renderers/view_renderers.js @@ -32,7 +32,7 @@ $.fn.dataTable.render.defaultRepositoryAssetValue = function() { }; $.fn.dataTable.render.RepositoryTextValue = function(data) { - var text = $(`${data.value.view}`); + const text = $(`${data.value.view}`); text.attr('data-edit-value', data.value.edit); return text.prop('outerHTML'); }; diff --git a/app/assets/javascripts/shared/inline_editing.js b/app/assets/javascripts/shared/inline_editing.js index 657fe46a43..0d5241d1cb 100644 --- a/app/assets/javascripts/shared/inline_editing.js +++ b/app/assets/javascripts/shared/inline_editing.js @@ -108,8 +108,10 @@ let inlineEditing = (function() { if (response.status === 403) { HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); } else if (response.status === 422) { - HelperModule.flashAlertMsg(response.responseJSON.errors - ? Object.values(response.responseJSON.errors).join(', ') : I18n.t('errors.general'), 'danger'); + const errors = response.responseJSON.errors || response.responseJSON; + if (!errors) { + HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger'); + } } if (!error) error = response.responseJSON.errors[fieldToUpdate]; container.addClass('error'); @@ -155,6 +157,7 @@ let inlineEditing = (function() { if (inputField(container).attr('disabled')) { saveAllEditFields(); let input = inputField(container); + input.val(container.attr('data-original-name')); input.attr('disabled', false) .removeClass('hidden') .focus(); diff --git a/app/assets/javascripts/sitewide/constants.js.erb b/app/assets/javascripts/sitewide/constants.js.erb index 6d3eeff905..ff1416e1e1 100644 --- a/app/assets/javascripts/sitewide/constants.js.erb +++ b/app/assets/javascripts/sitewide/constants.js.erb @@ -18,4 +18,6 @@ const GLOBAL_CONSTANTS = { SLOW_STATUS_POLLING_INTERVAL: <%= Constants::SLOW_STATUS_POLLING_INTERVAL %>, ASSET_POLLING_INTERVAL: <%= Constants::ASSET_POLLING_INTERVAL %>, ASSET_SYNC_URL: '<%= Constants::ASSET_SYNC_URL %>', + GLOBAL_SEARCH_PREVIEW_LIMIT: <%= Constants::GLOBAL_SEARCH_PREVIEW_LIMIT %>, + SEARCH_LIMIT: <%= Constants::SEARCH_LIMIT %> }; diff --git a/app/assets/javascripts/sitewide/pdf_preview.js b/app/assets/javascripts/sitewide/pdf_preview.js index dbaa0c758c..55301ccde5 100644 --- a/app/assets/javascripts/sitewide/pdf_preview.js +++ b/app/assets/javascripts/sitewide/pdf_preview.js @@ -115,7 +115,7 @@ var PdfPreview = (function() { function loadPdfDocument(canvas, page = 1) { - var loadingPdf = pdfjsLib.getDocument(canvas.dataset.pdfUrl); + var loadingPdf = pdfjsLib.getDocument({ url: canvas.dataset.pdfUrl, isEvalSupported: false }); $(canvas).data('load-attempts', $(canvas).data('load-attempts') + 1); loadingPdf.promise .then(function(pdfDocument) { diff --git a/app/assets/javascripts/sitewide/tooltips.js b/app/assets/javascripts/sitewide/tooltips.js new file mode 100644 index 0000000000..4eff0c26de --- /dev/null +++ b/app/assets/javascripts/sitewide/tooltips.js @@ -0,0 +1,23 @@ +window.initTooltip = (element) => { + $(element).tooltip({ + container: 'body', + delay: { show: 300, hide: 150 }, + trigger: 'hover', + placement: (_, source) => { + const position = $(source).attr('data-tooltip-placement'); + return position || 'top'; + }, + template: `` + }); +}; + +window.destroyTooltip = (element) => { + $(element).tooltip('destroy'); +}; + +$(document).on('turbolinks:load', function() { + $(document).find('[data-render-tooltip]').each(function() { + window.initTooltip(this); + }) +}); diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 5000948252..400c99bdf9 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -60,3 +60,15 @@ html { .ag-theme-alpine { --ag-font-family: "SN Inter", "Open Sans", Arial, Helvetica, sans-serif !important; } + +.animate-skeleton { + background-image: linear-gradient(90deg, #ddd 0px, #e8e8e8 40px, #ddd 80px); + background-size: 500px; + animation: shine-lines 1.6s infinite linear +} + +@keyframes shine-lines { + 0% { background-position: -150px } + + 40%, 100% { background-position: 320px } +} diff --git a/app/assets/stylesheets/experiment/canvas.scss b/app/assets/stylesheets/experiment/canvas.scss index 9009dffcf2..5099d877d3 100644 --- a/app/assets/stylesheets/experiment/canvas.scss +++ b/app/assets/stylesheets/experiment/canvas.scss @@ -12,6 +12,12 @@ .panel-heading { padding: 7px 30px 7px 15px; + + .panel-title { + align-items: center; + display: flex; + height: 100%; + } } .panel-body { @@ -97,4 +103,18 @@ grid-template-columns: 1fr; } } + + .bootstrap-select .dropdown-toggle:focus { + outline: none !important; + } + + .filter-option-inner { + height: 100%; + + .filter-option-inner-inner { + align-items: center; + display: flex; + height: 100%; + } + } } diff --git a/app/assets/stylesheets/reports_pdf.sass.scss b/app/assets/stylesheets/reports_pdf.sass.scss index f811678e9a..193fff54d0 100644 --- a/app/assets/stylesheets/reports_pdf.sass.scss +++ b/app/assets/stylesheets/reports_pdf.sass.scss @@ -28,3 +28,13 @@ thead { display: table-row-group; } +.report-module-repository-element { + .report-element-header { + .repository-name { + max-width: 100vw; + padding-bottom: 4px; + white-space: normal !important; + } + } +} + diff --git a/app/assets/stylesheets/shared/datetime_picker.scss b/app/assets/stylesheets/shared/datetime_picker.scss index 40db129ee1..561a5e1479 100644 --- a/app/assets/stylesheets/shared/datetime_picker.scss +++ b/app/assets/stylesheets/shared/datetime_picker.scss @@ -116,6 +116,10 @@ .dp__input { line-height: unset; + + &::placeholder { + color: var(--sn-grey); + } } } @@ -147,6 +151,13 @@ height: 36px; } } + + &.borderless-input { + .dp__input { + background-color: transparent; + border-color: transparent; + } + } } .dp__theme_light { @@ -182,7 +193,7 @@ &:hover { border-color: var(--sn-science-blue); } - border-color: var(--sn-science-blue); + border-color: var(--sn-science-blue) !important; } :root { diff --git a/app/assets/stylesheets/shared_styles/elements/checkboxes.scss b/app/assets/stylesheets/shared_styles/elements/checkboxes.scss index abf80f66f2..2fbc657b84 100644 --- a/app/assets/stylesheets/shared_styles/elements/checkboxes.scss +++ b/app/assets/stylesheets/shared_styles/elements/checkboxes.scss @@ -35,7 +35,7 @@ input[type="checkbox"].sci-checkbox { &::before { @include font-awesome; animation-timing-function: $timing-function-sharp; - background: $color-white; + background: transparent; border: 1px solid var(--sn-black); border-radius: 1px; color: $color-white; @@ -78,10 +78,14 @@ input[type="checkbox"].sci-checkbox { } &:focus { - outline: none; + outline: 0; outline-offset: 0; } + &:focus + .sci-checkbox-label { + outline: 4px solid var(--sn-science-blue-hover); + } + &:disabled { cursor: default; diff --git a/app/assets/stylesheets/tailwind/buttons.css b/app/assets/stylesheets/tailwind/buttons.css index 92a37e2e3c..9f70013624 100644 --- a/app/assets/stylesheets/tailwind/buttons.css +++ b/app/assets/stylesheets/tailwind/buttons.css @@ -4,7 +4,7 @@ } .btn { - @apply relative inline-flex items-center text-sm shrink-0 gap-2 justify-center px-4 rounded border border-solid appearance-none whitespace-nowrap cursor-pointer h-[40px] focus:outline-none; + @apply relative inline-flex items-center text-sm shrink-0 gap-2 justify-center px-4 rounded border border-solid appearance-none whitespace-nowrap cursor-pointer h-[40px]; border-color: transparent; } @@ -33,7 +33,7 @@ } .btn.btn-xs.icon-btn { - @apply px-0.5; + @apply px-0.5 w-[30px]; } .btn:hover { @@ -41,7 +41,7 @@ } .btn:focus { - @apply no-underline outline-none text-sn-white; + @apply no-underline outline outline-4 outline-sn-science-blue-hover text-sn-white; } .btn:active { @@ -58,6 +58,11 @@ @apply bg-sn-blue text-sn-white; } + .btn.btn-primary:active, + .btn.btn-primary.active { + @apply bg-sn-blue-click; + } + .btn.btn-primary:hover, .btn.btn-success:hover, .btn.btn-primary:focus, @@ -81,6 +86,11 @@ @apply bg-sn-science-blue text-sn-white border-sn-white; } + .btn.btn-secondary:active, + .btn.btn-secondary.active { + @apply bg-sn-super-light-blue; + } + .btn.btn-secondary:hover, .btn.btn-default:hover, .btn.btn-secondary:focus { @@ -123,6 +133,11 @@ @apply bg-sn-super-light-grey; } + .btn.btn-light:active, + .btn.btn-light.active { + @apply bg-sn-grey-100; + } + .btn.btn-light:disabled, .btn.btn-light.disabled { @apply text-sn-sleepy-grey; @@ -137,6 +152,11 @@ @apply bg-sn-delete-red-hover; } + .btn.btn-danger:active, + .btn.btn-danger.active { + @apply bg-sn-delete-red-click; + } + .btn.btn-danger:disabled, .btn.btn-danger.disabled { @apply bg-sn-delete-red-disabled; diff --git a/app/assets/stylesheets/tailwind/inputs.css b/app/assets/stylesheets/tailwind/inputs.css index 16389a0851..23a799bf22 100644 --- a/app/assets/stylesheets/tailwind/inputs.css +++ b/app/assets/stylesheets/tailwind/inputs.css @@ -21,7 +21,7 @@ } .sci-input-container-v2 input::placeholder { - @apply text-sn-sleepy-grey; + @apply text-sn-grey; } .sci-input-container-v2 .error { @@ -40,7 +40,8 @@ width: 100%; } - .sci-input-container-v2 input:focus { + .sci-input-container-v2 input:focus, + .sci-input-container-v2 input.active { @apply border-sn-science-blue shadow-none; } @@ -83,7 +84,7 @@ } .sci-input-container-v2 textarea::placeholder { - @apply text-sn-sleepy-grey; + @apply text-sn-grey; } .sci-input-container-v2 textarea:focus { diff --git a/app/controllers/dashboard/quick_start_controller.rb b/app/controllers/dashboard/quick_start_controller.rb index 1f05571c68..277ce4fa50 100644 --- a/app/controllers/dashboard/quick_start_controller.rb +++ b/app/controllers/dashboard/quick_start_controller.rb @@ -21,7 +21,9 @@ def create_task def project_filter projects = Project.readable_by_user(current_user) - .search(current_user, false, params[:query], 1, current_team) + .search(current_user, false, params[:query], current_team) + .page(params[:page] || 1) + .per(Constants::SEARCH_LIMIT) .select(:id, :name) projects = projects.map { |i| { value: i.id, label: escape_input(i.name) } } if (projects.map { |i| i[:label] }.exclude? params[:query]) && params[:query].present? @@ -36,7 +38,9 @@ def experiment_filter elsif @project experiments = @project.experiments .managable_by_user(current_user) - .search(current_user, false, params[:query], 1, current_team) + .search(current_user, false, params[:query], current_team) + .page(params[:page] || 1) + .per(Constants::SEARCH_LIMIT) .select(:id, :name) experiments = experiments.map { |i| { value: i.id, label: escape_input(i.name) } } if (experiments.map { |i| i[:label] }.exclude? params[:query]) && diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index bbc91737d8..347836601a 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class DashboardsController < ApplicationController + include TeamsHelper + + before_action :switch_team_with_param, only: :show + def show @my_module_status_flows = MyModuleStatusFlow.all.preload(my_module_statuses: :my_module_status_consequences) end diff --git a/app/controllers/experiments_controller.rb b/app/controllers/experiments_controller.rb index 96c6e3d1e8..11b26bfda4 100644 --- a/app/controllers/experiments_controller.rb +++ b/app/controllers/experiments_controller.rb @@ -116,7 +116,7 @@ def update render json: { message: t('experiments.update.success_flash', experiment: @experiment.name) }, status: :ok else - render json: { message: @experiment.errors.full_messages }, status: :unprocessable_entity + render json: { errors: @experiment.errors }, status: :unprocessable_entity end end @@ -452,6 +452,9 @@ def load_project @project = Project.find_by(id: params[:project_id]) render_404 unless @project + + current_team_switch(@project.team) if current_team != @project.team + render_403 unless can_read_project?(@project) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index c960766f97..7c17977056 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -40,7 +40,6 @@ def index end end - def inventory_assigning_project_filter viewable_experiments = Experiment.viewable_by_user(current_user, current_team) assignable_my_modules = MyModule.repository_row_assignable_by_user(current_user) @@ -260,7 +259,6 @@ def user_roles render json: { data: user_roles_collection(Project.new).map(&:reverse) } end - def actions_toolbar render json: { actions: diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb index 16b73b83e3..f0057d77ad 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -25,6 +25,7 @@ class RepositoriesController < ApplicationController before_action :check_create_permissions, only: %i(create_modal create) before_action :check_copy_permissions, only: %i(copy_modal copy) before_action :set_inline_name_editing, only: %i(show) + before_action :load_repository_row, only: %i(show) before_action :set_breadcrumbs_items, only: %i(index show) layout 'fluid' @@ -63,7 +64,7 @@ def show def table_toolbar render json: { - html: render_to_string(partial: 'repositories/toolbar_buttons') + html: render_to_string(partial: 'repositories/toolbar_buttons', locals: { view_mode: params[:view_mode]}) } end @@ -470,6 +471,14 @@ def load_repositories_for_restoring @repositories = current_team.repositories.archived.where(id: params[:repository_ids]) end + def load_repository_row + @repository_row = nil + @repository_row_landing_page = true if params[:landing_page].present? + return if params[:row_id].blank? + + @repository_row = @repository.repository_rows.find_by(id: params[:row_id]) + end + def set_inline_name_editing return unless can_manage_repository?(@repository) diff --git a/app/controllers/repository_columns/list_columns_controller.rb b/app/controllers/repository_columns/list_columns_controller.rb index c836f9bc46..1117c24d00 100644 --- a/app/controllers/repository_columns/list_columns_controller.rb +++ b/app/controllers/repository_columns/list_columns_controller.rb @@ -37,7 +37,7 @@ def items else @repository_column.repository_list_items .where('data ILIKE ?', "%#{search_params[:query]}%") - .limit(Constants::SEARCH_LIMIT) + .order(data: :asc) .select(:id, :data) end diff --git a/app/controllers/results_controller.rb b/app/controllers/results_controller.rb index 645d2acaa2..1a8301077e 100644 --- a/app/controllers/results_controller.rb +++ b/app/controllers/results_controller.rb @@ -165,7 +165,9 @@ def apply_sort! def apply_filters! if params[:query].present? - @results = @results.search(current_user, params[:view_mode] == 'archived', params[:query], params[:page] || 1) + @results = @results.search(current_user, params[:view_mode] == 'archived', params[:query]) + .page(params[:page] || 1) + .per(Constants::SEARCH_LIMIT) end @results = @results.where('results.created_at >= ?', params[:created_at_from]) if params[:created_at_from] @@ -194,7 +196,7 @@ def check_destroy_permissions def set_navigator @navigator = { url: tree_navigator_my_module_path(@my_module), - archived: false, + archived: @my_module.archived_branch?, id: @my_module.code } end diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 09cfc4e51a..a86d04a82a 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -4,302 +4,268 @@ class SearchController < ApplicationController before_action :load_vars, only: :index def index - redirect_to new_search_path unless @search_query - - @search_id = params[:search_id] ? params[:search_id] : generate_search_id - - count_search_results - - search_projects if @search_category == :projects - search_project_folders if @search_category == :project_folders - search_experiments if @search_category == :experiments - search_modules if @search_category == :modules - search_results if @search_category == :results - search_tags if @search_category == :tags - search_reports if @search_category == :reports - search_protocols if @search_category == :protocols - search_steps if @search_category == :steps - search_checklists if @search_category == :checklists - if @search_category == :repositories && params[:repository] - search_repository - end - search_assets if @search_category == :assets - search_tables if @search_category == :tables - search_comments if @search_category == :comments - - @search_pages = (@search_count.to_f / Constants::SEARCH_LIMIT.to_f).ceil - @start_page = @search_page - 2 - @start_page = 1 if @start_page < 1 - @end_page = @start_page + 4 - - if @end_page > @search_pages - @end_page = @search_pages - @start_page = @end_page - 4 - @start_page = 1 if @start_page < 1 - end - end - - def new - end - - private - - def load_vars - query = (params.fetch(:q) { '' }).strip - @search_category = params[:category] || '' - @search_category = @search_category.to_sym - @search_page = params[:page].to_i || 1 - @search_case = params[:match_case] == 'true' - @search_whole_word = params[:whole_word] == 'true' - @search_whole_phrase = params[:whole_phrase] == 'true' - @display_query = query - - if @search_whole_phrase || query.count(' ').zero? - if query.length < Constants::NAME_MIN_LENGTH - flash[:error] = t('general.query.length_too_short', - min_length: Constants::NAME_MIN_LENGTH) - redirect_back(fallback_location: root_path) - elsif query.length > Constants::TEXT_MAX_LENGTH - flash[:error] = t('general.query.length_too_long', - max_length: Constants::TEXT_MAX_LENGTH) - redirect_back(fallback_location: root_path) - else - @search_query = query + respond_to do |format| + format.html do + redirect_to new_search_path unless @search_query end - else - # splits the search query to validate all entries - splited_query = query.split - @search_query = '' - splited_query.each_with_index do |w, i| - if w.length >= Constants::NAME_MIN_LENGTH && - w.length <= Constants::TEXT_MAX_LENGTH - @search_query += "#{splited_query[i]} " + format.json do + redirect_to new_search_path unless @search_query + + case params[:group] + when 'projects' + @model = Project + search_by_name + + render json: @records.includes(:team, :project_folder), + each_serializer: GlobalSearch::ProjectSerializer, + meta: { + total: @records.total_count, + next_page: (@records.next_page if @records.respond_to?(:next_page)), + } + when 'project_folders' + @model = ProjectFolder + search_by_name + + render json: @records.includes(:team, :parent_folder), + each_serializer: GlobalSearch::ProjectFolderSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'reports' + @model = Report + search_by_name + + render json: @records.includes(:team, :project, :user), + each_serializer: GlobalSearch::ReportSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'module_protocols' + @model = Protocol + search_by_name({ in_repository: false }) + + render json: @records.includes({ my_module: :experiment }, :team), + each_serializer: GlobalSearch::MyModuleProtocolSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'experiments' + @model = Experiment + search_by_name + + render json: @records.includes(project: :team), + each_serializer: GlobalSearch::ExperimentSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'tasks' + @model = MyModule + search_by_name + + render json: @records.includes(experiment: { project: :team }), + each_serializer: GlobalSearch::MyModuleSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'results' + @model = Result + search_by_name + + render json: @records.includes(my_module: { experiment: { project: :team } }), + each_serializer: GlobalSearch::ResultSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'protocols' + @model = Protocol + search_by_name({ in_repository: true }) + + render json: @records, + each_serializer: GlobalSearch::ProtocolSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'label_templates' + return render json: [], meta: { disabled: true }, status: :ok unless LabelTemplate.enabled? + + @model = LabelTemplate + search_by_name + + render json: @records, + each_serializer: GlobalSearch::LabelTemplateSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'repository_rows' + @model = RepositoryRow + search_by_name(RepositoryRow) + + render json: @records, + each_serializer: GlobalSearch::RepositoryRowSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return + when 'assets' + @model = Asset + search_by_name + includes = [{ step: { protocol: { my_module: :experiment } } }, { result: { my_module: :experiment } }, :team] + + render json: @records.includes(includes), + each_serializer: GlobalSearch::AssetSerializer, + meta: { + total: @records.total_count, + next_page: @records.next_page + } + return end end - if @search_query.blank? - flash[:error] = t('general.query.wrong_query', - min_length: Constants::NAME_MIN_LENGTH, - max_length: Constants::TEXT_MAX_LENGTH) - redirect_back(fallback_location: root_path) - else - @search_query.strip! - end end - @search_page = 1 if @search_page < 1 end - protected - - def generate_search_id - SecureRandom.urlsafe_base64(32) + def new end - def search_by_name(model) - model.search(current_user, - true, - @search_query, - @search_page, - nil, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase) - .order(created_at: :desc) - end + def quick + results = if params[:filter].present? + object_quick_search(params[:filter].singularize) + else + Constants::QUICK_SEARCH_SEARCHABLE_OBJECTS.filter_map do |object| + next if object == 'label_template' && !LabelTemplate.enabled? - def count_by_name(model) - model.search(current_user, - true, - @search_query, - Constants::SEARCH_NO_LIMIT, - nil, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase).size + object_quick_search(object) + end.flatten.sort_by(&:updated_at).reverse.take(Constants::QUICK_SEARCH_LIMIT) + end + + render json: results, each_serializer: QuickSearchSerializer end - def count_by_repository - @repository_search_count = - Rails.cache.fetch("#{@search_id}/repository_search_count", - expires_in: 5.minutes) do - search_count = {} - search_results = Repository.search(current_user, - @search_query, - Constants::SEARCH_NO_LIMIT, - nil, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase) - - current_user.teams.includes(:repositories).each do |team| - team_results = {} - team_results[:team] = team - team_results[:count] = 0 - team_results[:repositories] = {} - Repository.accessible_by_teams(team).each do |repository| - repository_results = {} - repository_results[:id] = repository.id - repository_results[:repository] = repository - repository_results[:count] = 0 - search_results.each do |result| - repository_results[:count] += result.counter if repository.id == result.id - end - team_results[:repositories][repository.name] = repository_results - team_results[:count] += repository_results[:count] - end - search_count[team.name] = team_results - end - search_count - end + private - count_total = 0 - @repository_search_count.each_value do |team_results| - count_total += team_results[:count] - end - count_total + def object_quick_search(class_name) + search_model = class_name.to_s.camelize.constantize + search_object_classes = ["#{class_name.pluralize}.name"] + search_object_classes << search_model::PREFIXED_ID_SQL if search_model.respond_to?(:code) + + search_model.search_by_search_fields_with_boolean(current_user, + current_team, + params[:query], + search_object_classes, + limit: Constants::QUICK_SEARCH_LIMIT, + fetch_latest_versions: class_name == 'protocol') + .order(updated_at: :desc) end - def current_repository_search_count - @repository_search_count.each_value do |counter| - res = counter[:repositories].values.detect do |rep| - rep[:id] == @repository.id + def load_vars + query = (params.fetch(:q) { '' }).strip + @filters = params[:filters] + @include_archived = @filters.blank? || @filters[:include_archived] == 'true' + @teams = (@filters.present? && @filters[:teams]&.values) || current_user.teams + @display_query = query + + splited_query = query.split + @search_query = '' + splited_query.each_with_index do |w, i| + if w.length >= Constants::NAME_MIN_LENGTH && + w.length <= Constants::TEXT_MAX_LENGTH + @search_query += "#{splited_query[i]} " end - return res[:count] if res && res[:count] end - end - - def count_search_results - @project_search_count = fetch_cached_count Project - @project_folder_search_count = fetch_cached_count ProjectFolder - @experiment_search_count = fetch_cached_count Experiment - @module_search_count = fetch_cached_count MyModule - @result_search_count = fetch_cached_count Result - @tag_search_count = fetch_cached_count Tag - @report_search_count = fetch_cached_count Report - @protocol_search_count = fetch_cached_count Protocol - @step_search_count = fetch_cached_count Step - @checklist_search_count = fetch_cached_count Checklist - @repository_search_count_total = count_by_repository - @asset_search_count = fetch_cached_count Asset - @table_search_count = fetch_cached_count Table - @comment_search_count = fetch_cached_count Comment - - @search_results_count = @project_search_count - @search_results_count += @project_folder_search_count - @search_results_count += @experiment_search_count - @search_results_count += @module_search_count - @search_results_count += @result_search_count - @search_results_count += @tag_search_count - @search_results_count += @report_search_count - @search_results_count += @protocol_search_count - @search_results_count += @step_search_count - @search_results_count += @checklist_search_count - @search_results_count += @repository_search_count_total - @search_results_count += @asset_search_count - @search_results_count += @table_search_count - @search_results_count += @comment_search_count - end - - def fetch_cached_count(type) - exp = 5.minutes - Rails.cache.fetch( - "#{@search_id}/#{type.name.underscore}_search_count", expires_in: exp - ) do - count_by_name type + if @search_query.blank? + flash[:error] = t('general.query.wrong_query', + min_length: Constants::NAME_MIN_LENGTH, + max_length: Constants::TEXT_MAX_LENGTH) + redirect_back(fallback_location: root_path) + else + @search_query.strip! end end - def search_projects - @project_results = [] - @project_results = search_by_name(Project) if @project_search_count.positive? - @search_count = @project_search_count - end - - def search_project_folders - @project_folder_results = [] - @project_folder_results = search_by_name(ProjectFolder) if @project_folder_search_count.positive? - @search_count = @project_folder_search_count - end - - def search_experiments - @experiment_results = [] - @experiment_results = search_by_name(Experiment) if @experiment_search_count.positive? - @search_count = @experiment_search_count - end - - def search_modules - @module_results = [] - @module_results = search_by_name(MyModule) if @module_search_count.positive? - @search_count = @module_search_count - end - - def search_results - @result_results = [] - @result_results = search_by_name(Result) if @result_search_count.positive? - @search_count = @result_search_count - end - - def search_tags - @tag_results = [] - @tag_results = search_by_name(Tag) if @tag_search_count.positive? - @search_count = @tag_search_count - end + protected - def search_reports - @report_results = [] - @report_results = search_by_name(Report) if @report_search_count.positive? - @search_count = @report_search_count + def search_by_name(options = {}) + @records = @model.search(current_user, + @include_archived, + @search_query, + nil, + teams: @teams, + users: @users, + options: options) + + filter_records if @filters.present? + sort_records + paginate_records end - def search_protocols - @protocol_results = [] - @protocol_results = search_by_name(Protocol) if @protocol_search_count.positive? - @search_count = @protocol_search_count + def filter_records + filter_datetime!(:created_at) if @filters[:created_at].present? + filter_datetime!(:updated_at) if @filters[:updated_at].present? + filter_users! if @filters[:users].present? end - def search_steps - @step_results = [] - @step_results = search_by_name(Step) if @step_search_count.positive? - @search_count = @step_search_count + def sort_records + @records = case params[:sort] + when 'atoz' + sort_attribute = @model.name == 'Asset' ? 'active_storage_blobs.filename' : 'name' + @records.order(sort_attribute => :asc) + when 'ztoa' + sort_attribute = @model.name == 'Asset' ? 'active_storage_blobs.filename' : 'name' + @records.order(sort_attribute => :desc) + when 'created_asc' + @records.order(created_at: :asc) + else + @records.order(created_at: :desc) + end end - def search_checklists - @checklist_results = [] - @checklist_results = search_by_name(Checklist) if @checklist_search_count.positive? - @search_count = @checklist_search_count + def paginate_records + @records = if params[:preview] == 'true' + @records.page(params[:page]).per(Constants::GLOBAL_SEARCH_PREVIEW_LIMIT) + else + @records.page(params[:page]).per(Constants::SEARCH_LIMIT) + end end - def search_repository - @repository = Repository.find_by(id: params[:repository]) - unless current_user.teams.include?(@repository.team) || @repository.private_shared_with?(current_user.teams) - render_403 - end - @repository_results = [] - if @repository_search_count_total.positive? - @repository_results = - Repository.search(current_user, @search_query, @search_page, - @repository, - match_case: @search_case, - whole_word: @search_whole_word, - whole_phrase: @search_whole_phrase) + def filter_datetime!(attribute) + model_name = @model.model_name.collection + if @filters[attribute][:on].present? + from_date = Time.zone.parse(@filters[attribute][:on]).beginning_of_day.utc + to_date = Time.zone.parse(@filters[attribute][:on]).end_of_day.utc end - @search_count = current_repository_search_count - end - def search_assets - @asset_results = [] - @asset_results = search_by_name(Asset) if @asset_search_count.positive? - @search_count = @asset_search_count - end + from_date = Time.zone.parse(@filters[attribute][:from]) if @filters[attribute][:from].present? + to_date = Time.zone.parse(@filters[attribute][:to]) if @filters[attribute][:to].present? - def search_tables - @table_results = [] - @table_results = search_by_name(Table) if @table_search_count.positive? - @search_count = @table_search_count + @records = @records.where("#{model_name}.#{attribute} >= ?", from_date) if from_date.present? + @records = @records.where("#{model_name}.#{attribute} <= ?", to_date) if to_date.present? end - def search_comments - @comment_results = [] - @comment_results = search_by_name(Comment) if @comment_search_count.positive? - @search_count = @comment_search_count + def filter_users! + @records = @records.joins("INNER JOIN activities ON #{@model.model_name.collection}.id = activities.subject_id + AND activities.subject_type= '#{@model.name}'") + + user_ids = @filters[:users]&.values + @records = if @model.name == 'MyModule' + @records.where('activities.owner_id IN (?) OR users.id IN (?)', user_ids, user_ids) + else + @records.where('activities.owner_id': user_ids) + end end end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb index 965f079ef9..61707f01b6 100644 --- a/app/controllers/teams_controller.rb +++ b/app/controllers/teams_controller.rb @@ -9,9 +9,23 @@ class TeamsController < ApplicationController before_action :load_vars, only: %i(sidebar export_projects export_projects_modal disable_tasks_sharing_modal shared_tasks_toggle) before_action :load_current_folder, only: :sidebar - before_action :check_read_permissions, except: :view_type + before_action :check_read_permissions, except: %i(view_type visible_teams visible_users) before_action :check_export_projects_permissions, only: %i(export_projects_modal export_projects) + def visible_teams + teams = current_user.teams.order(:name) + render json: teams, each_serializer: TeamSerializer + end + + def visible_users + teams = current_user.teams + if params[:teams].present? + teams = teams.where(id: params[:teams]) + end + users = User.where(id: teams.joins(:users).select('users.id')).order(:full_name) + render json: users, each_serializer: UserSerializer, user: current_user + end + def sidebar render json: { html: render_to_string( diff --git a/app/javascript/packs/vue/global_search.js b/app/javascript/packs/vue/global_search.js new file mode 100644 index 0000000000..d315a598e8 --- /dev/null +++ b/app/javascript/packs/vue/global_search.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue/dist/vue.esm-bundler.js'; +import PerfectScrollbar from 'vue3-perfect-scrollbar'; +import GlobalSearch from '../../vue/global_search/container.vue'; +import { mountWithTurbolinks } from './helpers/turbolinks.js'; + +const app = createApp(); +app.component('global_search', GlobalSearch); +app.config.globalProperties.i18n = window.I18n; +app.use(PerfectScrollbar); +mountWithTurbolinks(app, '#GlobalSearch'); diff --git a/app/javascript/packs/vue/repository_item_error_sidebar.js b/app/javascript/packs/vue/repository_item_error_sidebar.js new file mode 100644 index 0000000000..ebe9a4c410 --- /dev/null +++ b/app/javascript/packs/vue/repository_item_error_sidebar.js @@ -0,0 +1,12 @@ +/* global */ + +import PerfectScrollbar from 'vue3-perfect-scrollbar'; +import { createApp } from 'vue/dist/vue.esm-bundler.js'; +import { mountWithTurbolinks } from './helpers/turbolinks.js'; +import RepositoryItemErrorSidebar from '../../vue/repository_item_sidebar/RepositoryItemErrorSidebar.vue'; + +const app = createApp({}); +app.component('RepositoryItemErrorSidebar', RepositoryItemErrorSidebar); +app.use(PerfectScrollbar); +app.config.globalProperties.i18n = window.I18n; +mountWithTurbolinks(app, '#repositoryItemErrorSidebar'); diff --git a/app/javascript/vue/experiments/card.vue b/app/javascript/vue/experiments/card.vue index 0a1be59ab7..d4a8c71d2d 100644 --- a/app/javascript/vue/experiments/card.vue +++ b/app/javascript/vue/experiments/card.vue @@ -1,6 +1,6 @@ @@ -80,13 +77,20 @@
{{ i18n.t('experiments.canvas.modal_manage_tags.create_new') }}
-
+
+ -