').html(data.heading).text());
- modalBody.html(data.html);
+ const modalContent = modal.find('.modal-content');
+
+ modalContent.html(data.html);
// Show the modal
modal.modal('show');
@@ -122,13 +121,14 @@
'ajax:error',
"[data-action='destroy-user-team']",
function() {
- // TODO
+ HelperModule.flashAlertMsg(I18n.t('users.settings.user_teams.general_error'), 'danger');
}
);
// Also, bind the click action on the modal
$('#destroy-user-team-modal')
.on('click', "[data-action='submit']", function() {
+ animateSpinner();
var btn = $(this);
var form = btn
.closest('.modal')
@@ -154,14 +154,16 @@
// Hide the modal
modal.modal('hide');
+ animateSpinner(null, false);
// Reload the whole table
- usersDatatable.ajax.reload();
+ location.reload();
}
).on(
'ajax:error',
"[data-id='destroy-user-team-form']",
function() {
- // TODO
+ animateSpinner(null, false);
+ HelperModule.flashAlertMsg(I18n.t('users.settings.user_teams.general_error'), 'danger');
}
);
}
diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css
index 500094825..400c99bdf 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 9009dffcf..5099d877d 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 f811678e9..193fff54d 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 40db129ee..561a5e147 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 abf80f66f..f2a7850fe 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;
diff --git a/app/assets/stylesheets/tailwind/buttons.css b/app/assets/stylesheets/tailwind/buttons.css
index 92a37e2e3..9f7001362 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 aadc8957f..6ea4e568f 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/asset_sync_controller.rb b/app/controllers/asset_sync_controller.rb
index 855bfba53..7f9e9561e 100644
--- a/app/controllers/asset_sync_controller.rb
+++ b/app/controllers/asset_sync_controller.rb
@@ -5,7 +5,7 @@ class AssetSyncController < ApplicationController
skip_before_action :authenticate_user!, only: %i(update download)
skip_before_action :verify_authenticity_token, only: %i(update download)
- before_action :authenticate_asset_sync_token!, only: %i(update download)
+ prepend_before_action :authenticate_asset_sync_token!, only: %i(update download)
before_action :check_asset_sync
def show
@@ -117,7 +117,8 @@ class AssetSyncController < ApplicationController
render_error(:unauthorized) and return unless @asset_sync_token&.token_valid?
@asset = @asset_sync_token.asset
- @current_user = @asset_sync_token.user
+
+ sign_in(@asset_sync_token.user)
render_error(:forbidden, @asset.file.filename) and return unless can_manage_asset?(@asset)
end
diff --git a/app/controllers/dashboard/quick_start_controller.rb b/app/controllers/dashboard/quick_start_controller.rb
index 2930b335d..a16cc8f2e 100644
--- a/app/controllers/dashboard/quick_start_controller.rb
+++ b/app/controllers/dashboard/quick_start_controller.rb
@@ -22,7 +22,9 @@ module Dashboard
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| [i.id, escape_input(i.name)] }
if (projects.map { |i| i[1] }.exclude? params[:query]) && params[:query].present?
@@ -37,7 +39,9 @@ module Dashboard
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| [i.id, escape_input(i.name)] }
if (experiments.map { |i| i[1] }.exclude? params[:query]) &&
diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb
index bbc91737d..347836601 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 96c6e3d1e..11b26bfda 100644
--- a/app/controllers/experiments_controller.rb
+++ b/app/controllers/experiments_controller.rb
@@ -116,7 +116,7 @@ class ExperimentsController < ApplicationController
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 @@ class ExperimentsController < ApplicationController
@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/label_templates_controller.rb b/app/controllers/label_templates_controller.rb
index 8727edcce..ebd32e464 100644
--- a/app/controllers/label_templates_controller.rb
+++ b/app/controllers/label_templates_controller.rb
@@ -4,8 +4,8 @@ class LabelTemplatesController < ApplicationController
include InputSanitizeHelper
include TeamsHelper
- before_action :check_feature_enabled, except: %i(index zpl_preview)
- before_action :load_label_templates, only: %i(index datatable)
+ before_action :check_feature_enabled, except: %i(index zpl_preview list)
+ before_action :load_label_templates, only: %i(index datatable list)
before_action :load_label_template, only: %i(show set_default update template_tags)
before_action :check_view_permissions, except: %i(create duplicate set_default delete update)
before_action :check_manage_permissions, only: %i(create duplicate set_default delete update)
@@ -29,6 +29,10 @@ class LabelTemplatesController < ApplicationController
end
end
+ def list
+ render json: @label_templates, each_serializer: LabelTemplateSerializer, user: current_user
+ end
+
def show
respond_to do |format|
format.json { render json: @label_template, serializer: LabelTemplateSerializer, user: current_user }
diff --git a/app/controllers/protocols_controller.rb b/app/controllers/protocols_controller.rb
index b86cd8ce0..b0ade7fb4 100644
--- a/app/controllers/protocols_controller.rb
+++ b/app/controllers/protocols_controller.rb
@@ -371,7 +371,11 @@ class ProtocolsController < ApplicationController
def save_as_draft
Protocol.transaction do
- draft = @protocol.save_as_draft(current_user)
+ draft = nil
+
+ @protocol.with_lock do
+ draft = @protocol.save_as_draft(current_user)
+ end
if draft.invalid?
render json: { error: draft.errors.messages.map { |_, value| value }.join(' ') }, status: :unprocessable_entity
diff --git a/app/controllers/repositories_controller.rb b/app/controllers/repositories_controller.rb
index 0a326d5f2..5b0fcacca 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)
before_action :validate_file_type, only: %i(export_repository export_repositories)
@@ -494,6 +495,14 @@ class RepositoriesController < ApplicationController
@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)
@@ -587,11 +596,11 @@ class RepositoriesController < ApplicationController
def set_breadcrumbs_items
@breadcrumbs_items = []
- archived_branch = @repository&.archived? || (!@repository && params[:archived] == 'true')
+ archived_branch = @repository&.archived? || (!@repository && params[:view_mode] == 'archived')
@breadcrumbs_items.push({
label: t('breadcrumbs.inventories'),
- url: archived_branch ? repositories_path(archived: true) : repositories_path,
+ url: archived_branch ? repositories_path(view_mode: 'archived') : repositories_path,
archived: archived_branch
})
diff --git a/app/controllers/repository_columns/list_columns_controller.rb b/app/controllers/repository_columns/list_columns_controller.rb
index 79d87de78..1117c24d0 100644
--- a/app/controllers/repository_columns/list_columns_controller.rb
+++ b/app/controllers/repository_columns/list_columns_controller.rb
@@ -32,11 +32,14 @@ module RepositoryColumns
end
def items
- column_list_items = @repository_column.repository_list_items
- .where('data ILIKE ?',
- "%#{search_params[:query]}%")
- .limit(Constants::SEARCH_LIMIT)
- .select(:id, :data)
+ column_list_items = if params[:all_options]
+ @repository_column.repository_list_items.select(:id, :data)
+ else
+ @repository_column.repository_list_items
+ .where('data ILIKE ?', "%#{search_params[:query]}%")
+ .order(data: :asc)
+ .select(:id, :data)
+ end
render json: column_list_items.map { |i| { value: i.id, label: escape_input(i.data) } }, status: :ok
end
diff --git a/app/controllers/results_controller.rb b/app/controllers/results_controller.rb
index 289167312..3be461b3d 100644
--- a/app/controllers/results_controller.rb
+++ b/app/controllers/results_controller.rb
@@ -176,7 +176,9 @@ class ResultsController < ApplicationController
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]
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 09cfc4e51..c0d57f0e3 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -4,302 +4,246 @@ class SearchController < ApplicationController
before_action :load_vars, only: :index
def index
- redirect_to new_search_path unless @search_query
+ respond_to do |format|
+ format.html do
+ redirect_to new_search_path unless @search_query
+ end
+ format.json do
+ redirect_to new_search_path unless @search_query
- @search_id = params[:search_id] ? params[:search_id] : generate_search_id
+ case params[:group]
+ when 'projects'
+ search_by_name(Project)
- count_search_results
+ 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'
+ search_by_name(ProjectFolder)
- 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
+ render json: @records.includes(:team, :parent_folder),
+ each_serializer: GlobalSearch::ProjectFolderSerializer,
+ meta: {
+ total: @records.total_count,
+ next_page: @records.next_page
+ }
+ return
+ when 'reports'
+ search_by_name(Report)
- @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
+ 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'
+ search_by_name(Protocol, { in_repository: false })
- if @end_page > @search_pages
- @end_page = @search_pages
- @start_page = @end_page - 4
- @start_page = 1 if @start_page < 1
+ render json: @records.joins({ my_module: :experiment }, :team),
+ each_serializer: GlobalSearch::MyModuleProtocolSerializer,
+ meta: {
+ total: @records.total_count,
+ next_page: @records.next_page
+ }
+ return
+ when 'experiments'
+ search_by_name(Experiment)
+
+ render json: @records.includes(project: :team),
+ each_serializer: GlobalSearch::ExperimentSerializer,
+ meta: {
+ total: @records.total_count,
+ next_page: @records.next_page
+ }
+ return
+ when 'tasks'
+ search_by_name(MyModule)
+
+ render json: @records.includes(experiment: { project: :team }),
+ each_serializer: GlobalSearch::MyModuleSerializer,
+ meta: {
+ total: @records.total_count,
+ next_page: @records.next_page
+ }
+ return
+ when 'results'
+ search_by_name(Result)
+
+ 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'
+ search_by_name(Protocol, { 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?
+
+ search_by_name(LabelTemplate)
+
+ render json: @records,
+ each_serializer: GlobalSearch::LabelTemplateSerializer,
+ meta: {
+ total: @records.total_count,
+ next_page: @records.next_page
+ }
+ return
+ when 'repository_rows'
+ search_by_name(RepositoryRow)
+
+ render json: @records,
+ each_serializer: GlobalSearch::RepositoryRowSerializer,
+ meta: {
+ total: @records.total_count,
+ next_page: @records.next_page
+ }
+ return
+ when 'assets'
+ search_by_name(Asset)
+ 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
end
end
def new
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?
+
+ object_quick_search(object)
+ end.flatten.sort_by(&:updated_at).reverse.take(Constants::QUICK_SEARCH_LIMIT)
+ end
+
+ render json: results, each_serializer: QuickSearchSerializer
+ end
+
private
+ def object_quick_search(class_name)
+ search_model = class_name.to_s.camelize.constantize
+ search_method = search_model.method(search_model.respond_to?(:code) ? :search_by_name_and_id : :search_by_name)
+
+ search_method.call(current_user,
+ current_team,
+ params[:query],
+ limit: Constants::QUICK_SEARCH_LIMIT)
+ .order(updated_at: :desc)
+ end
+
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'
+ @filters = params[:filters]
+ @include_archived = @filters.blank? || @filters[:include_archived] == 'true'
+ @teams = (@filters.present? && @filters[:teams]&.values) || current_user.teams
@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
- 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]} "
- 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!
+ 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
end
- @search_page = 1 if @search_page < 1
+ 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
protected
- def generate_search_id
- SecureRandom.urlsafe_base64(32)
+ def search_by_name(model, options = {})
+ @records = model.search(current_user,
+ @include_archived,
+ @search_query,
+ nil,
+ teams: @teams,
+ users: @users,
+ options: options)
+
+ filter_records(model) if @filters.present?
+ sort_records
+ paginate_records
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)
+ def filter_records(model)
+ filter_datetime!(model, :created_at) if @filters[:created_at].present?
+ filter_datetime!(model, :updated_at) if @filters[:updated_at].present?
+ filter_users!(model) if @filters[:users].present?
end
- 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
+ def sort_records
+ @records = case params[:sort]
+ when 'atoz'
+ @records.order(name: :asc)
+ when 'ztoa'
+ @records.order(name: :desc)
+ when 'created_asc'
+ @records.order(created_at: :asc)
+ else
+ @records.order(created_at: :desc)
+ end
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)
+ 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
- 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
-
- count_total = 0
- @repository_search_count.each_value do |team_results|
- count_total += team_results[:count]
+ def filter_datetime!(model, 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
+ elsif @filters[attribute][:from].present? && @filters[attribute][:to].present?
+ from_date = Time.zone.parse(@filters[attribute][:from])
+ to_date = Time.zone.parse(@filters[attribute][:to])
end
- count_total
+
+ @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 current_repository_search_count
- @repository_search_count.each_value do |counter|
- res = counter[:repositories].values.detect do |rep|
- rep[:id] == @repository.id
- 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
- 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
-
- def search_reports
- @report_results = []
- @report_results = search_by_name(Report) if @report_search_count.positive?
- @search_count = @report_search_count
- end
-
- def search_protocols
- @protocol_results = []
- @protocol_results = search_by_name(Protocol) if @protocol_search_count.positive?
- @search_count = @protocol_search_count
- end
-
- def search_steps
- @step_results = []
- @step_results = search_by_name(Step) if @step_search_count.positive?
- @search_count = @step_search_count
- end
-
- def search_checklists
- @checklist_results = []
- @checklist_results = search_by_name(Checklist) if @checklist_search_count.positive?
- @search_count = @checklist_search_count
- 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)
- 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
-
- def search_tables
- @table_results = []
- @table_results = search_by_name(Table) if @table_search_count.positive?
- @search_count = @table_search_count
- 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!(model)
+ @records = @records.joins("INNER JOIN activities ON #{model.model_name.collection}.id = activities.subject_id
+ AND activities.subject_type= '#{model.name}'")
+ .where('activities.owner_id': @filters[:users]&.values)
end
end
diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb
index 965f079ef..36b9c2c12 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
+ 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/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb
index d0bc18a6a..6fefaf3c7 100644
--- a/app/controllers/users/omniauth_callbacks_controller.rb
+++ b/app/controllers/users/omniauth_callbacks_controller.rb
@@ -8,7 +8,7 @@ module Users
skip_before_action :verify_authenticity_token
before_action :sign_up_with_provider_enabled?,
only: :linkedin
- before_action :check_sso_status, only: %i(customazureactivedirectory okta)
+ before_action :check_sso_status, only: %i(customazureactivedirectory okta openid_connect)
# You should configure your model like this:
# devise :omniauthable, omniauth_providers: [:twitter]
@@ -46,17 +46,7 @@ module Users
if user.blank?
# Create new user and identity
- full_name = "#{auth.info.first_name} #{auth.info.last_name}"
- user = User.new(full_name: full_name,
- initials: generate_initials(full_name),
- email: email,
- password: generate_user_password)
- User.transaction do
- user.save!
- user.user_identities.create!(provider: auth.provider, uid: auth.uid)
- user.update!(confirmed_at: user.created_at)
- end
-
+ user = create_user_from_auth(email, auth)
sign_in_and_redirect(user, event: :authentication)
elsif provider_conf['auto_link_on_sign_in']
# Link to existing local account
@@ -147,16 +137,7 @@ module Users
user = User.find_by(email: auth.info.email.downcase)
if user.blank?
- # Create new user and identity
- user = User.new(full_name: auth.info.name,
- initials: generate_initials(auth.info.name),
- email: auth.info.email,
- password: generate_user_password)
- User.transaction do
- user.save!
- user.user_identities.create!(provider: auth.provider, uid: auth.uid)
- user.update!(confirmed_at: user.created_at)
- end
+ user = create_user_from_auth(email, auth)
else
# Link to existing local account
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
@@ -177,6 +158,107 @@ module Users
end
end
+ def openid_connect
+ auth = request.env['omniauth.auth']
+ settings = ApplicationSettings.instance
+ provider_conf = settings.values['openid_connect']
+ raise StandardError, 'No matching OpenID Connect AD provider config found' if provider_conf.blank?
+
+ return redirect_to connected_accounts_path if current_user
+
+ email = auth.info.email
+ email ||= auth.dig(:extra, :raw_info, :id_token_claims, :emails)&.first
+ user = User.from_omniauth(auth)
+
+ # User found in database so just signing in
+ return sign_in_and_redirect(user) if user.present?
+
+ if email.blank?
+ # No email in the token so can not link or create user
+ error_message = I18n.t('devise.openid_connect.errors.no_email')
+ return redirect_to after_omniauth_failure_path_for(resource_name)
+ end
+
+ user = User.find_by(email: email.downcase)
+
+ if user.blank?
+ # Create new user and identity
+ user = create_user_from_auth(email, auth)
+ sign_in_and_redirect(user)
+ elsif provider_conf['auto_link_on_sign_in']
+ # Link to existing local account
+ user.user_identities.create!(provider: auth.provider, uid: auth.uid)
+ user.update!(confirmed_at: user.created_at) if user.confirmed_at.blank?
+ sign_in_and_redirect(user)
+ else
+ # Cannot do anything with it, so just return an error
+ error_message = I18n.t('devise.openid_connect.errors.no_local_user_map')
+ redirect_to after_omniauth_failure_path_for(resource_name)
+ end
+ rescue StandardError => e
+ Rails.logger.error e.message
+ Rails.logger.error e.backtrace.join("\n")
+ error_message = I18n.t('devise.openid_connect.errors.failed_to_save') if e.is_a?(ActiveRecord::RecordInvalid)
+ error_message ||= I18n.t('devise.openid_connect.errors.generic')
+ redirect_to after_omniauth_failure_path_for(resource_name)
+ ensure
+ if error_message
+ set_flash_message(:alert, :failure, kind: I18n.t('devise.openid_connect.provider_name'), reason: error_message)
+ else
+ set_flash_message(:notice, :success, kind: I18n.t('devise.openid_connect.provider_name'))
+ end
+ end
+
+ def saml
+ auth = request.env['omniauth.auth']
+
+ settings = ApplicationSettings.instance
+ provider_conf = settings.values['saml']
+ raise StandardError, 'No matching SAML provider config found' if provider_conf.blank?
+
+ return redirect_to connected_accounts_path if current_user
+
+ email = auth.info.email
+ user = User.from_omniauth(auth)
+
+ # User found in database so just signing in
+ return sign_in_and_redirect(user) if user.present?
+
+ if email.blank?
+ # No email in the token so can not link or create user
+ error_message = I18n.t('devise.saml.errors.no_email')
+ return redirect_to after_omniauth_failure_path_for(resource_name)
+ end
+
+ user = User.find_by(email: email.downcase)
+
+ if user.blank?
+ user = create_user_from_auth(email, auth)
+ sign_in_and_redirect(user)
+ elsif provider_conf['auto_link_on_sign_in']
+ # Link to existing local account
+ user.user_identities.create!(provider: auth.provider, uid: auth.uid)
+ user.update!(confirmed_at: user.created_at) if user.confirmed_at.blank?
+ sign_in_and_redirect(user)
+ else
+ # Cannot do anything with it, so just return an error
+ error_message = I18n.t('devise.saml.errors.no_local_user_map')
+ redirect_to after_omniauth_failure_path_for(resource_name)
+ end
+ rescue StandardError => e
+ Rails.logger.error e.message
+ Rails.logger.error e.backtrace.join("\n")
+ error_message = I18n.t('devise.saml.errors.failed_to_save') if e.is_a?(ActiveRecord::RecordInvalid)
+ error_message ||= I18n.t('devise.saml.errors.generic')
+ redirect_to after_omniauth_failure_path_for(resource_name)
+ ensure
+ if error_message
+ set_flash_message(:alert, :failure, kind: I18n.t('devise.saml.provider_name'), reason: error_message)
+ else
+ set_flash_message(:notice, :success, kind: I18n.t('devise.saml.provider_name'))
+ end
+ end
+
# More info at:
# https://github.com/plataformatec/devise#omniauth
@@ -213,5 +295,33 @@ module Users
initials = initials.strip.blank? ? 'PLCH' : initials[0..3]
initials
end
+
+ def create_user_from_auth(email, auth)
+ full_name = "#{auth.info.first_name} #{auth.info.last_name}"
+ user = User.new(full_name: full_name,
+ initials: generate_initials(full_name),
+ email: email,
+ password: generate_user_password)
+ User.transaction do
+ user.save!
+ user.user_identities.create!(provider: auth.provider, uid: auth.uid)
+ user.update!(confirmed_at: user.created_at)
+ end
+ user
+ end
+
+ def create_user_from_auth(email, auth)
+ full_name = "#{auth.info.first_name} #{auth.info.last_name}"
+ user = User.new(full_name: full_name,
+ initials: generate_initials(full_name),
+ email: email,
+ password: generate_user_password)
+ User.transaction do
+ user.save!
+ user.user_identities.create!(provider: auth.provider, uid: auth.uid)
+ user.update!(confirmed_at: user.created_at)
+ end
+ user
+ end
end
end
diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb
index 30df7aaa9..6bac88f13 100644
--- a/app/controllers/users/passwords_controller.rb
+++ b/app/controllers/users/passwords_controller.rb
@@ -21,7 +21,7 @@ class Users::PasswordsController < Devise::PasswordsController
if resource.errors.blank?
resource.unlock_access! if unlockable?(resource)
- if !resource.two_factor_auth_enabled?
+ if !two_factor_auth_enabled_for(resource)
flash_message = resource.active_for_authentication? ? :updated : :updated_not_active
set_flash_message!(:notice, flash_message)
resource.after_database_authentication
@@ -39,7 +39,11 @@ class Users::PasswordsController < Devise::PasswordsController
protected
def after_resetting_password_path_for(resource)
- resource.two_factor_auth_enabled? ? new_session_path(resource_name) : after_sign_in_path_for(resource)
+ two_factor_auth_enabled_for(resource) ? new_session_path(resource_name) : after_sign_in_path_for(resource)
+ end
+
+ def two_factor_auth_enabled_for(user)
+ user.two_factor_auth_enabled?
end
# The path used after sending reset password instructions
diff --git a/app/controllers/users/settings/user_teams_controller.rb b/app/controllers/users/settings/user_teams_controller.rb
index 9118f81d4..536dac21b 100644
--- a/app/controllers/users/settings/user_teams_controller.rb
+++ b/app/controllers/users/settings/user_teams_controller.rb
@@ -48,14 +48,9 @@ module Users
render json: {
html: render_to_string(
partial: 'users/settings/user_teams/' \
- 'destroy_user_team_modal_body',
+ 'destroy_user_team_modal_body',
locals: { user_assignment: @user_assignment },
formats: :html
- ),
- heading: I18n.t(
- 'users.settings.user_teams.destroy_uo_heading',
- user: escape_input(@user_assignment.user.full_name),
- team: escape_input(@user_assignment.assignable.name)
)
}
end
@@ -63,29 +58,12 @@ module Users
def destroy
# If user is last administrator of team,
# he/she cannot be deleted from it.
- invalid =
- managing_team_user_roles_collection.include?(@user_assignment.user_role) &&
- @user_assignment
- .assignable
- .user_assignments
- .where(user_role: managing_team_user_roles_collection)
- .count <= 1
+ invalid = @user_assignment.last_with_permission?(TeamPermissions::USERS_MANAGE)
unless invalid
begin
@user_assignment.transaction do
- # If user leaves on his/her own accord,
- # new owner for projects is the first
- # administrator of team
if params[:leave]
- new_owner =
- @user_assignment
- .assignable
- .user_assignments
- .where(user_role: managing_team_user_roles_collection)
- .where.not(id: @user_assignment.id)
- .first
- .user
Activities::CreateActivityService
.call(activity_type: :user_leave_team,
owner: current_user,
@@ -95,10 +73,6 @@ module Users
team: @user_assignment.assignable.id
})
else
- # Otherwise, the new owner for projects is
- # the current user (= an administrator removing
- # the user from the team)
- new_owner = current_user
Activities::CreateActivityService
.call(activity_type: :remove_user_from_team,
owner: current_user,
@@ -110,8 +84,7 @@ module Users
})
end
reset_user_current_team(@user_assignment)
-
- remove_user_from_team!(@user_assignment, new_owner)
+ @user_assignment.destroy!
end
rescue StandardError => e
Rails.logger.error e.message
@@ -119,21 +92,27 @@ module Users
end
end
- if !invalid
- if params[:leave]
- flash[:notice] = I18n.t(
- 'users.settings.user_teams.leave_flash',
- team: @user_assignment.assignable.name
- )
- flash.keep(:notice)
- end
+ if invalid
+ render json: @user_assignment.errors, status: :unprocessable_entity
+ else
+ flash[:success] = if params[:leave]
+ I18n.t(
+ 'users.settings.user_teams.leave_flash',
+ team: @user_assignment.assignable.name
+ )
+ else
+ I18n.t(
+ 'users.settings.user_teams.remove_flash',
+ user: @user_assignment.user.full_name,
+ team: @user_assignment.assignable.name
+ )
+ end
+
generate_notification(current_user,
@user_assignment.user,
@user_assignment.assignable,
false)
render json: { status: :ok }
- else
- render json: @user_assignment.errors, status: :unprocessable_entity
end
end
@@ -165,33 +144,6 @@ module Users
user_assignment.user.current_team_id = ids.first
user_assignment.user.save
end
-
- def remove_user_from_team!(user_assignment, new_owner)
- return user_assignment.destroy! unless new_owner
-
- # Also, make new owner author of all protocols that belong
- # to the departing user and belong to this team.
- p_ids = user_assignment.user.added_protocols.where(team: user_assignment.assignable).pluck(:id)
- Protocol.where(id: p_ids).find_each do |protocol|
- protocol.record_timestamps = false
- protocol.added_by = new_owner
- protocol.archived_by = new_owner if protocol.archived_by == user_assignment.user
- protocol.restored_by = new_owner if protocol.restored_by == user_assignment.user
- protocol.save!(validate: false)
- protocol.user_assignments.find_by(user: new_owner)&.destroy!
- protocol.user_assignments.create!(
- user: new_owner,
- user_role: UserRole.find_predefined_owner_role,
- assigned: :manually
- )
- end
-
- # Make new owner author of all inventory items that were added
- # by departing user and belong to this team.
- RepositoryRow.change_owner(user_assignment.assignable, user_assignment.user, new_owner)
-
- user_assignment.destroy!
- end
end
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index e84f9a003..8bb1ae8cd 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -199,12 +199,21 @@ module ApplicationHelper
ENV['SSO_ENABLED'] == 'true'
end
- def okta_configured?
- ApplicationSettings.instance.values['okta'].present?
+ def okta_enabled?
+ ApplicationSettings.instance.values.dig('okta', 'enabled')
end
- def azure_ad_configured?
- ApplicationSettings.instance.values['azure_ad_apps'].present?
+ def azure_ad_enabled?
+ provider_conf = ApplicationSettings.instance.values['azure_ad_apps']
+ provider_conf.present? && provider_conf[0]['enabled']
+ end
+
+ def saml_enabled?
+ ApplicationSettings.instance.values.dig('saml', 'enabled')
+ end
+
+ def openid_connect_enabled?
+ ApplicationSettings.instance.values.dig('openid_connect', 'enabled')
end
def wopi_enabled?
@@ -213,7 +222,7 @@ module ApplicationHelper
# Check whether the wopi file can be edited and return appropriate response
def wopi_file_edit_button_status(asset)
- file_ext = asset.file_name.split('.').last
+ file_ext = asset.file_name.split('.').last&.downcase
if Constants::WOPI_EDITABLE_FORMATS.include?(file_ext)
edit_supported = true
title = ''
diff --git a/app/helpers/file_icons_helper.rb b/app/helpers/file_icons_helper.rb
index b119d078b..7c01548d3 100644
--- a/app/helpers/file_icons_helper.rb
+++ b/app/helpers/file_icons_helper.rb
@@ -62,7 +62,7 @@ module FileIconsHelper
# For showing in view/edit icon url (WOPI)
def file_application_url(asset)
- file_ext = asset.file_name.split('.').last
+ file_ext = asset.file_name.split('.').last&.downcase
if Constants::FILE_TEXT_FORMATS.include?(file_ext)
'icon_small/docx_file.svg'
elsif Constants::FILE_TABLE_FORMATS.include?(file_ext)
@@ -73,7 +73,7 @@ module FileIconsHelper
end
def sn_icon_for(asset)
- file_ext = asset.file_name.split('.').last
+ file_ext = asset.file_name.split('.').last&.downcase
if Constants::FILE_TEXT_FORMATS.include?(file_ext)
'file-word'
elsif Constants::FILE_TABLE_FORMATS.include?(file_ext)
@@ -95,7 +95,7 @@ module FileIconsHelper
# Shows correct WOPI application text (Word Online/Excel ..)
def wopi_button_text(asset, action)
- file_ext = asset.file_name.split('.').last
+ file_ext = asset.file_name.split('.').last&.downcase
if Constants::FILE_TEXT_FORMATS.include?(file_ext)
app = I18n.t('result_assets.wopi_word')
elsif Constants::FILE_TABLE_FORMATS.include?(file_ext)
diff --git a/app/javascript/packs/vue/global_search.js b/app/javascript/packs/vue/global_search.js
new file mode 100644
index 000000000..d315a598e
--- /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 000000000..ebe9a4c41
--- /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 0a1be59ab..ebfb3f9c6 100644
--- a/app/javascript/vue/experiments/card.vue
+++ b/app/javascript/vue/experiments/card.vue
@@ -1,6 +1,6 @@
-
+
-
+
diff --git a/app/javascript/vue/experiments/renderers/completed_tasks.vue b/app/javascript/vue/experiments/renderers/completed_tasks.vue
index c44859e20..bc63741a9 100644
--- a/app/javascript/vue/experiments/renderers/completed_tasks.vue
+++ b/app/javascript/vue/experiments/renderers/completed_tasks.vue
@@ -33,7 +33,8 @@ export default {
progress() {
const { completed_tasks: completedTasks, total_tasks: totalTasks } = this.params.data;
- if (totalTasks === 0) return 0;
+ if (totalTasks === 0) return 3;
+ if (completedTasks === 0) return 3;
return (completedTasks / totalTasks) * 100;
}
diff --git a/app/javascript/vue/experiments/renderers/description.vue b/app/javascript/vue/experiments/renderers/description.vue
index e118c4fe6..5b694b478 100644
--- a/app/javascript/vue/experiments/renderers/description.vue
+++ b/app/javascript/vue/experiments/renderers/description.vue
@@ -1,32 +1,32 @@
-
-
-
+
+
+
-
-
+
{{ i18n.t('experiments.card.more') }}
+
+
+
+
+
+
+
+
+ {{ i18n.t('experiments.card.more') }}
+
+
+
+
diff --git a/app/javascript/vue/global_search/filters.vue b/app/javascript/vue/global_search/filters.vue
new file mode 100644
index 000000000..b23f288a7
--- /dev/null
+++ b/app/javascript/vue/global_search/filters.vue
@@ -0,0 +1,234 @@
+
+
+
+
{{ i18n.t('search.filters.by_type') }}
+
+
+
+
+
+
{{ i18n.t('search.filters.by_created_date') }}
+
{this.createdAt = v}"
+ >
+
{{ i18n.t('search.filters.by_updated_date') }}
+
{this.updatedAt = v}"
+ >
+
{{ i18n.t('search.filters.by_team') }}
+
{selectedTeams = v}" />
+
+ {{ i18n.t('search.filters.by_user') }}
+
+
+ {selectedUsers = v}" />
+
+
+
+
+
+ {{ i18n.t('search.filters.include_archived') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/filters/date.vue b/app/javascript/vue/global_search/filters/date.vue
new file mode 100644
index 000000000..d716d0458
--- /dev/null
+++ b/app/javascript/vue/global_search/filters/date.vue
@@ -0,0 +1,152 @@
+
+
+
{selectedOption = v}" />
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/filters_modal.vue b/app/javascript/vue/global_search/filters_modal.vue
new file mode 100644
index 000000000..420e8bf00
--- /dev/null
+++ b/app/javascript/vue/global_search/filters_modal.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/assets.vue b/app/javascript/vue/global_search/groups/assets.vue
new file mode 100644
index 000000000..6d5475299
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/assets.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.files') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/experiments.vue b/app/javascript/vue/global_search/groups/experiments.vue
new file mode 100644
index 000000000..014e45222
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/experiments.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.experiments') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/folders.vue b/app/javascript/vue/global_search/groups/folders.vue
new file mode 100644
index 000000000..fbb5c1940
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/folders.vue
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.folders') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/helpers/cell_template.vue b/app/javascript/vue/global_search/groups/helpers/cell_template.vue
new file mode 100644
index 000000000..724b04084
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/helpers/cell_template.vue
@@ -0,0 +1,33 @@
+
+
+
+ {{ label }}:
+
+
+
+
+
+
+
{{ value }}
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/helpers/link_template.vue b/app/javascript/vue/global_search/groups/helpers/link_template.vue
new file mode 100644
index 000000000..831cdd1c7
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/helpers/link_template.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/helpers/list_end.vue b/app/javascript/vue/global_search/groups/helpers/list_end.vue
new file mode 100644
index 000000000..262d9c06b
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/helpers/list_end.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+ {{ i18n.t('search.index.reached_end') }}
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/helpers/no_search_result.vue b/app/javascript/vue/global_search/groups/helpers/no_search_result.vue
new file mode 100644
index 000000000..6a89405c3
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/helpers/no_search_result.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.no_results_text') }}
+
+
+ {{ i18n.t('search.index.adjust_search_text') }}
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/helpers/sort_flyout.vue b/app/javascript/vue/global_search/groups/helpers/sort_flyout.vue
new file mode 100644
index 000000000..cb4a8e26d
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/helpers/sort_flyout.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/label_templates.vue b/app/javascript/vue/global_search/groups/label_templates.vue
new file mode 100644
index 000000000..39070f8eb
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/label_templates.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.label_templates') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/my_module_protocols.vue b/app/javascript/vue/global_search/groups/my_module_protocols.vue
new file mode 100644
index 000000000..92205366c
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/my_module_protocols.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.task_protocols') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/my_modules.vue b/app/javascript/vue/global_search/groups/my_modules.vue
new file mode 100644
index 000000000..7f7e00433
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/my_modules.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.tasks') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/projects.vue b/app/javascript/vue/global_search/groups/projects.vue
new file mode 100644
index 000000000..70a05d49b
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/projects.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.projects') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/protocols.vue b/app/javascript/vue/global_search/groups/protocols.vue
new file mode 100644
index 000000000..e269d8a9c
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/protocols.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.protocol_templates') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/reports.vue b/app/javascript/vue/global_search/groups/reports.vue
new file mode 100644
index 000000000..3ff1dcf88
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/reports.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.reports') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/repository_rows.vue b/app/javascript/vue/global_search/groups/repository_rows.vue
new file mode 100644
index 000000000..4982438cc
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/repository_rows.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.inventory_items') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/results.vue b/app/javascript/vue/global_search/groups/results.vue
new file mode 100644
index 000000000..0cf4c5eba
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/results.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {{ i18n.t('search.index.task_results') }}
+ [{{ total }}]
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/global_search/groups/search_mixin.js b/app/javascript/vue/global_search/groups/search_mixin.js
new file mode 100644
index 000000000..fed580c6d
--- /dev/null
+++ b/app/javascript/vue/global_search/groups/search_mixin.js
@@ -0,0 +1,140 @@
+import axios from '../../../packs/custom_axios.js';
+import StringWithEllipsis from '../../shared/string_with_ellipsis.vue';
+import SortFlyout from './helpers/sort_flyout.vue';
+import Loader from '../loader.vue';
+import ListEnd from './helpers/list_end.vue';
+import NoSearchResult from './helpers/no_search_result.vue';
+import CellTemplate from './helpers/cell_template.vue';
+import LinkTemplate from './helpers/link_template.vue';
+/* global GLOBAL_CONSTANTS I18n */
+
+export default {
+ props: {
+ searchUrl: String,
+ query: String,
+ selected: Boolean,
+ filters: Object
+ },
+ components: {
+ StringWithEllipsis,
+ SortFlyout,
+ Loader,
+ NoSearchResult,
+ ListEnd,
+ CellTemplate,
+ LinkTemplate
+ },
+ data() {
+ return {
+ sort: 'created_desc',
+ results: [],
+ total: 0,
+ loading: false,
+ page: 1,
+ disabled: false,
+ fullDataLoaded: false,
+ };
+ },
+ watch: {
+ filters() {
+ this.reloadData();
+ },
+ selected() {
+ if (this.selected && !this.fullDataLoaded) {
+ this.reloadData();
+ }
+ },
+ query() {
+ this.reloadData();
+ }
+ },
+ mounted() {
+ this.loadData();
+ window.addEventListener('scroll', this.handleScroll);
+ },
+ unmounted() {
+ window.removeEventListener('scroll', this.handleScroll);
+ },
+ computed: {
+ preparedResults() {
+ if (this.selected) {
+ return this.results;
+ }
+ return this.results.slice(0, 4);
+ },
+ viewAll() {
+ return !this.selected && this.total > GLOBAL_CONSTANTS.GLOBAL_SEARCH_PREVIEW_LIMIT;
+ },
+ loaderRows() {
+ return !this.selected ? 4 : 20;
+ },
+ reachedEnd() {
+ return !this.page && this.selected;
+ },
+ showNoSearchResult() {
+ return this.selected && !this.loading && !this.results.length;
+ }
+ },
+ methods: {
+ labelName(object) {
+ if (!object) return '';
+
+ if (!object.archived) return object.name;
+
+ return `${I18n.t('labels.archived')} ${object.name}`;
+ },
+ handleScroll() {
+ if (this.loading || !this.selected) return;
+
+ if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
+ if (this.results.length < this.total) {
+ this.loadData();
+ }
+ }
+ },
+ changeSort(sort) {
+ this.sort = sort;
+ this.results = [];
+ this.page = 1;
+ this.loadData();
+ },
+ reloadData() {
+ if (this.query.length > 1) {
+ this.results = [];
+ this.page = 1;
+ this.total = 0;
+ this.fullDataLoaded = false;
+ this.loadData();
+ }
+ },
+ loadData() {
+ if (this.query.length < 2) return;
+
+ if (this.loading && this.page) return;
+
+ this.loading = true;
+ axios.get(this.searchUrl, {
+ params: {
+ q: this.query,
+ sort: this.sort,
+ filters: this.filters,
+ group: this.group,
+ preview: !this.selected,
+ page: this.page
+ }
+ })
+ .then((response) => {
+ if (this.selected) this.fullDataLoaded = true;
+ this.results = this.results.concat(response.data.data);
+ this.total = response.data.meta.total;
+ this.disabled = response.data.meta.disabled;
+ this.loading = false;
+ this.page = response.data.meta.next_page;
+ })
+ .finally(() => {
+ this.loading = false;
+ this.$emit('updated');
+ });
+ }
+ }
+};
diff --git a/app/javascript/vue/global_search/loader.vue b/app/javascript/vue/global_search/loader.vue
new file mode 100644
index 000000000..a11f038d6
--- /dev/null
+++ b/app/javascript/vue/global_search/loader.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/app/javascript/vue/my_modules/modals/tags.vue b/app/javascript/vue/my_modules/modals/tags.vue
index e1710acec..9a6ae0f3f 100644
--- a/app/javascript/vue/my_modules/modals/tags.vue
+++ b/app/javascript/vue/my_modules/modals/tags.vue
@@ -37,7 +37,7 @@
-
+
-
+
+
@@ -80,13 +77,20 @@
{{ i18n.t('experiments.canvas.modal_manage_tags.create_new') }}
-
+
a
@@ -104,11 +108,13 @@
+
-
-
-
{{ params.data.tags[0].name }}
-
-
+
-
+
+
{{ params.data.tags[0].name }}
+
+
+{{ params.data.tags.length - 1 }}
diff --git a/app/javascript/vue/navigation/quick_search.vue b/app/javascript/vue/navigation/quick_search.vue
new file mode 100644
index 000000000..dd4f0375f
--- /dev/null
+++ b/app/javascript/vue/navigation/quick_search.vue
@@ -0,0 +1,332 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(A)
+
+
+ {{ result.attributes.updated_at }}
+
+
+
+
+ /
+ {{ breadcrumb }}
+
+
+
+
+
+
+
+
{{ i18n.t('search.quick_search.empty_title', {team: currentTeamName}) }}
+
+ {{ i18n.t('search.quick_search.empty_description', {query: searchQuery}) }}
+
+
+
+
+
+ {{ i18n.t('search.quick_search.all_results', {query: searchQuery}) }}
+
+
+
+
+
+
+
diff --git a/app/javascript/vue/navigation/top_menu.vue b/app/javascript/vue/navigation/top_menu.vue
index 4464cb416..adfc69b9e 100644
--- a/app/javascript/vue/navigation/top_menu.vue
+++ b/app/javascript/vue/navigation/top_menu.vue
@@ -1,16 +1,21 @@