Merge branch 'develop' into features/inventory-import-improvements

This commit is contained in:
Anton 2024-06-05 14:42:00 +02:00
commit 49d0f9e6b8
108 changed files with 1371 additions and 710 deletions

View file

@ -60,7 +60,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

View file

@ -439,7 +439,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)
@ -462,12 +462,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)
@ -615,7 +615,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)
@ -707,6 +708,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)
@ -834,7 +836,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

View file

@ -1 +1 @@
1.33.0
1.34.0.2

View file

@ -30,7 +30,7 @@
// listen for input event on the my_module_name input field
$(`${newMyModuleModal} #my_module_name`).on('input', function () {
if ($(this).val().trim() !== '') {
if ($(this).val().trim().length > 1) {
// enable the submit button if the input field is populated
$('#new-modal-submit-btn').prop('disabled', false);
} else {

View file

@ -147,10 +147,11 @@
// initialize my_module tab remote loading
$('#experimentTable, .my-modules-protocols-index, #experiment-canvas')
.on('click', '.edit-tags-link', function() {
if($('#tagsModalComponent').length) {
$('#tagsModalComponent').data('tagsModal').open()
if (window.tagsModal) {
const url = $(this).closest('.module-large').data('module-url') || $(this).attr('href');
window.tagsModal.open(url);
}
})
});
}
bindEditTagsAjax();

View file

@ -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() {
@ -969,7 +976,7 @@ function bindEditTagsAjax(elements) {
$(elements).find("a.edit-tags-link")
.on('click', function(){
$(this).addClass('updated-module-tags');
var modal = $(this).closest(".panel-default").find('.tags-modal-component').data('tagsModal').open();
var modal = $('#tagsModalComponent');
});
}

View file

@ -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) {

View file

@ -104,8 +104,4 @@
}
}
});
$(document).on('turbolinks:load', () => {
$('#itemLandingPagelink').trigger('click');
});
}());

View file

@ -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: `<div class="tooltip" role="tooltip"><div class="tooltip-arrow">
</div><div class="tooltip-inner !max-w-[250px] text-left bg-black text-sn-white text-xs p-2"></div></div>`
});
};
window.destroyTooltip = (element) => {
$(element).tooltip('destroy');
};
$(document).on('turbolinks:load', function() {
$(document).find('[data-render-tooltip]').each(function() {
window.initTooltip(this);
})
});

View file

@ -201,23 +201,27 @@ var zebraPrint = (function() {
});
}
updateProgressModalData(progressModal, printData.printer_name, PRINTER_STATUS_SEARCH, PRINTER_STATUS_SEARCH);
device = findDevice(printData.printer_name);
if (dataZebra?.responseJSON?.labels?.length && printData.number_of_copies > 0) {
updateProgressModalData(progressModal, printData.printer_name, PRINTER_STATUS_SEARCH, PRINTER_STATUS_SEARCH);
device = findDevice(printData.printer_name);
getPrinterStatus(device).then((device) => {
if (device.status === I18n.t('label_printers.modal_printing_status.printer_status.ready')) {
print(
device,
progressModal,
printData.number_of_copies,
printData.printer_name,
dataZebra.responseJSON.labels,
0,
);
} else {
updateProgressModalData(progressModal, printData.printer_name, PRINTER_STATUS_ERROR, PRINTER_STATUS_ERROR);
}
});
getPrinterStatus(device).then((device) => {
if (device.status === I18n.t('label_printers.modal_printing_status.printer_status.ready')) {
print(
device,
progressModal,
printData.number_of_copies,
printData.printer_name,
dataZebra.responseJSON.labels,
0
);
} else {
updateProgressModalData(progressModal, printData.printer_name, PRINTER_STATUS_ERROR, PRINTER_STATUS_ERROR);
}
});
} else {
updateProgressModalData(progressModal, printData.printer_name, PRINTER_STATUS_ERROR, PRINTER_STATUS_ERROR);
}
}).fail(() => {
HelperModule.flashAlertMsg(I18n.t('repository_row.modal_print_label.general_error'), 'danger');
});

View file

@ -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;

View file

@ -106,6 +106,14 @@ module Api
)
end
rescue_from Api::V1::ApiKeyError do |e|
render_error(
e.message,
I18n.t('api.core.invalid_api_key_detail'),
:unauthorized
)
end
before_action :check_include_param, only: %i(index show)
def index

View file

@ -18,7 +18,11 @@ class AssetsController < ApplicationController
before_action :load_vars, except: :create_wopi_file
before_action :check_read_permission, except: %i(edit destroy duplicate create_wopi_file toggle_view_mode)
<<<<<<< HEAD
before_action :check_manage_permission, only: %i(edit destroy duplicate toggle_view_mode rename)
=======
before_action :check_manage_permission, only: %i(edit destroy duplicate rename toggle_view_mode)
>>>>>>> develop
def file_preview
render json: { html: render_to_string(
@ -311,6 +315,49 @@ class AssetsController < ApplicationController
end
end
def rename
new_name = params.require(:asset).permit(:name)[:name]
if new_name.empty?
render json: { error: I18n.t('assets.rename_modal.min_length_error') }, status: :unprocessable_entity
return
elsif new_name.length > Constants::NAME_MAX_LENGTH
render json: { error: I18n.t('assets.rename_modal.max_length_error') }, status: :unprocessable_entity
return
end
ActiveRecord::Base.transaction do
old_name = @asset.file_name
@asset.last_modified_by = current_user
@asset.rename_file(new_name)
@asset.save!
case @asset.parent
when Step
message_items = { old_name:, new_name:, user: current_user.id }
message_items[:my_module] = @assoc.protocol.my_module.id if @assoc.protocol.in_module?
@asset.parent.touch
log_step_activity(
"#{@assoc.protocol.in_module? ? 'task' : 'protocol'}_step_asset_renamed",
@assoc,
@assoc.my_module&.project,
message_items
)
when Result
log_result_activity(
:result_asset_renamed,
@assoc,
old_name:,
new_name:,
user: current_user.id,
my_module: @assoc.my_module.id
)
end
end
render json: @asset, serializer: AssetSerializer, user: current_user
end
def duplicate
ActiveRecord::Base.transaction do
case @asset.parent
@ -348,49 +395,6 @@ class AssetsController < ApplicationController
end
end
def rename
new_name = params.require(:asset).permit(:name)[:name]
if new_name.empty?
render json: { error: I18n.t('assets.rename_modal.min_length_error') }, status: :unprocessable_entity
return
elsif new_name.length > Constants::NAME_MAX_LENGTH
render json: { error: I18n.t('assets.rename_modal.max_length_error') }, status: :unprocessable_entity
return
end
ActiveRecord::Base.transaction do
old_name = @asset.name
@asset.last_modified_by = current_user
@asset.rename_file(new_name)
@asset.save!
case @asset.parent
when Step
message_items = { old_name: old_name, new_name: new_name, user: current_user.id }
message_items[:my_module] = @assoc.protocol.my_module.id if @assoc.protocol.in_module?
log_step_activity(
"#{@assoc.protocol.in_module? ? 'task' : 'protocol'}_step_asset_renamed",
@assoc,
@assoc.my_module&.project,
message_items
)
when Result
log_result_activity(
:result_asset_renamed,
@assoc,
old_name: old_name,
new_name: new_name,
user: current_user.id,
my_module: @assoc.my_module.id
)
end
end
render json: @asset, serializer: AssetSerializer, user: current_user
end
def checksum
render json: { checksum: @asset.file.blob.checksum }
end

View file

@ -1,5 +1,12 @@
# frozen_string_literal: true
module Api
module V1
class ApiKeyError < StandardError
end
end
end
module TokenAuthentication
extend ActiveSupport::Concern
@ -13,7 +20,23 @@ module TokenAuthentication
raise JWT::InvalidPayload, I18n.t('api.core.no_azure_user_mapping') unless current_user
end
def authenticate_with_api_key
return unless Rails.configuration.x.core_api_key_enabled
@api_key = request.headers['Api-Key']
return unless @api_key
@current_user = User.from_api_key(@api_key)
raise Api::V1::ApiKeyError, I18n.t('api.core.invalid_api_key') unless @current_user
@current_user
end
def authenticate_request!
# API key authentication successful
return if authenticate_with_api_key
@token = request.headers['Authorization']&.sub('Bearer ', '')
raise JWT::VerificationError, I18n.t('api.core.missing_token') unless @token

View file

@ -40,7 +40,6 @@ class ProjectsController < ApplicationController
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 @@ class ProjectsController < ApplicationController
render json: { data: user_roles_collection(Project.new).map(&:reverse) }
end
def actions_toolbar
render json: {
actions:

View file

@ -73,7 +73,7 @@ class RepositoriesController < ApplicationController
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

View file

@ -122,7 +122,7 @@ class ResultsController < ApplicationController
def destroy
name = @result.name
if @result.destroy
if @result.discard
log_activity(:destroy_result, { destroyed_result: name })
render json: {}, status: :ok
else
@ -207,7 +207,7 @@ class ResultsController < ApplicationController
def set_navigator
@navigator = {
url: tree_navigator_my_module_path(@my_module),
archived: false,
archived: @my_module.archived_branch?,
id: @my_module.code
}
end

View file

@ -13,7 +13,8 @@ class SearchController < ApplicationController
case params[:group]
when 'projects'
search_by_name(Project)
@model = Project
search_by_name
render json: @records.includes(:team, :project_folder),
each_serializer: GlobalSearch::ProjectSerializer,
@ -22,7 +23,8 @@ class SearchController < ApplicationController
next_page: (@records.next_page if @records.respond_to?(:next_page)),
}
when 'project_folders'
search_by_name(ProjectFolder)
@model = ProjectFolder
search_by_name
render json: @records.includes(:team, :parent_folder),
each_serializer: GlobalSearch::ProjectFolderSerializer,
@ -32,7 +34,8 @@ class SearchController < ApplicationController
}
return
when 'reports'
search_by_name(Report)
@model = Report
search_by_name
render json: @records.includes(:team, :project, :user),
each_serializer: GlobalSearch::ReportSerializer,
@ -42,9 +45,10 @@ class SearchController < ApplicationController
}
return
when 'module_protocols'
search_by_name(Protocol, { in_repository: false })
@model = Protocol
search_by_name({ in_repository: false })
render json: @records.joins({ my_module: :experiment }, :team),
render json: @records.includes({ my_module: :experiment }, :team),
each_serializer: GlobalSearch::MyModuleProtocolSerializer,
meta: {
total: @records.total_count,
@ -52,7 +56,8 @@ class SearchController < ApplicationController
}
return
when 'experiments'
search_by_name(Experiment)
@model = Experiment
search_by_name
render json: @records.includes(project: :team),
each_serializer: GlobalSearch::ExperimentSerializer,
@ -62,7 +67,8 @@ class SearchController < ApplicationController
}
return
when 'tasks'
search_by_name(MyModule)
@model = MyModule
search_by_name
render json: @records.includes(experiment: { project: :team }),
each_serializer: GlobalSearch::MyModuleSerializer,
@ -72,7 +78,8 @@ class SearchController < ApplicationController
}
return
when 'results'
search_by_name(Result)
@model = Result
search_by_name
render json: @records.includes(my_module: { experiment: { project: :team } }),
each_serializer: GlobalSearch::ResultSerializer,
@ -82,7 +89,8 @@ class SearchController < ApplicationController
}
return
when 'protocols'
search_by_name(Protocol, { in_repository: true })
@model = Protocol
search_by_name({ in_repository: true })
render json: @records,
each_serializer: GlobalSearch::ProtocolSerializer,
@ -94,7 +102,8 @@ class SearchController < ApplicationController
when 'label_templates'
return render json: [], meta: { disabled: true }, status: :ok unless LabelTemplate.enabled?
search_by_name(LabelTemplate)
@model = LabelTemplate
search_by_name
render json: @records,
each_serializer: GlobalSearch::LabelTemplateSerializer,
@ -104,6 +113,7 @@ class SearchController < ApplicationController
}
return
when 'repository_rows'
@model = RepositoryRow
search_by_name(RepositoryRow)
render json: @records,
@ -114,7 +124,8 @@ class SearchController < ApplicationController
}
return
when 'assets'
search_by_name(Asset)
@model = Asset
search_by_name
includes = [{ step: { protocol: { my_module: :experiment } } }, { result: { my_module: :experiment } }, :team]
render json: @records.includes(includes),
@ -150,13 +161,16 @@ class SearchController < ApplicationController
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_object_classes = ["#{class_name.pluralize}.name"]
search_object_classes << search_model::PREFIXED_ID_SQL if search_model.respond_to?(:code)
search_method.call(current_user,
current_team,
params[:query],
limit: Constants::QUICK_SEARCH_LIMIT)
.order(updated_at: :desc)
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 load_vars
@ -186,32 +200,34 @@ class SearchController < ApplicationController
protected
def search_by_name(model, options = {})
@records = model.search(current_user,
@include_archived,
@search_query,
nil,
teams: @teams,
users: @users,
options: options)
def search_by_name(options = {})
@records = @model.search(current_user,
@include_archived,
@search_query,
nil,
teams: @teams,
users: @users,
options: options)
filter_records(model) if @filters.present?
filter_records if @filters.present?
sort_records
paginate_records
end
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?
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 sort_records
@records = case params[:sort]
when 'atoz'
@records.order(name: :asc)
sort_attribute = @model.name == 'Asset' ? 'active_storage_blobs.filename' : 'name'
@records.order(sort_attribute => :asc)
when 'ztoa'
@records.order(name: :desc)
sort_attribute = @model.name == 'Asset' ? 'active_storage_blobs.filename' : 'name'
@records.order(sort_attribute => :desc)
when 'created_asc'
@records.order(created_at: :asc)
else
@ -227,23 +243,29 @@ class SearchController < ApplicationController
end
end
def filter_datetime!(model, attribute)
model_name = model.model_name.collection
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
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
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?
@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 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)
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

View file

@ -13,7 +13,7 @@ class TeamsController < ApplicationController
before_action :check_export_projects_permissions, only: %i(export_projects_modal export_projects)
def visible_teams
teams = current_user.teams
teams = current_user.teams.order(:name)
render json: teams, each_serializer: TeamSerializer
end

View file

@ -207,6 +207,24 @@ class Users::RegistrationsController < Devise::RegistrationsController
render json: { qr_code: create_2fa_qr_code(current_user) }
end
def regenerate_api_key
current_user.regenerate_api_key!
redirect_to(edit_user_registration_path(anchor: 'api-key'),
flash: {
success: t('users.registrations.edit.api_key.generated')
})
end
def revoke_api_key
current_user.revoke_api_key!
redirect_to(edit_user_registration_path(anchor: 'api-key'),
flash: {
success: t('users.registrations.edit.api_key.revoked')
})
end
protected
# Called upon creating User (before .save). Permits parameters and extracts

View file

@ -4,63 +4,69 @@ import { createApp } from 'vue/dist/vue.esm-bundler.js';
import TagsModal from '../../../vue/my_modules/modals/tags.vue';
import { mountWithTurbolinks } from '../helpers/turbolinks.js';
window.initTagsModalComponent = (id) => {
const app = createApp({
data() {
return {
tagsModalOpen: false
};
},
mounted() {
$(this.$refs.tagsModal).data('tagsModal', this);
},
methods: {
open() {
this.tagsModalOpen = true;
},
close() {
this.tagsModalOpen = false;
},
syncTags(tags) {
// My module page
if ($('#module-tags-selector').length) {
const assignedTags = tags.filter((i) => i.assigned).map((i) => (
{
value: i.id,
label: i.attributes.name,
params: {
color: i.attributes.color
}
}
));
dropdownSelector.setData('#module-tags-selector', assignedTags);
const app = createApp({
data() {
return {
myModuleParams: null,
tagsModalOpen: false,
tagsUrl: null
};
},
mounted() {
window.tagsModal = this;
},
beforeUnmount() {
delete window.tagsModal;
},
methods: {
open(myModuleUrl) {
$.ajax({
url: myModuleUrl,
type: 'GET',
dataType: 'json',
success: (data) => {
this.myModuleParams = { ...data.data.attributes, id: data.data.id };
this.tagsModalOpen = true;
}
});
},
close() {
this.myModuleParams = null;
this.tagsModalOpen = false;
},
syncTags(tags) {
// My module page
if ($('#module-tags-selector').length) {
const assignedTags = tags.filter((i) => i.assigned).map((i) => (
{
value: i.id,
label: i.attributes.name,
params: {
color: i.attributes.color
}
}
));
dropdownSelector.setData('#module-tags-selector', assignedTags);
}
// Canvas
if ($('#canvas-container').length) {
$.ajax({
url: $('#canvas-container').attr('data-module-tags-url'),
type: 'GET',
dataType: 'json',
success(data) {
$.each(data.my_modules, (index, myModule) => {
$(`div.panel[data-module-id='${myModule.id}']`)
.find('.edit-tags-link')
.html(myModule.tags_html);
});
}
});
}
// Canvas
if ($('#canvas-container').length) {
$.ajax({
url: this.tagsUrl,
type: 'GET',
dataType: 'json',
success(data) {
$.each(data.my_modules, (index, myModule) => {
$(`div.panel[data-module-id='${myModule.id}']`)
.find('.edit-tags-link')
.html(myModule.tags_html);
});
}
});
}
}
});
app.component('tags-modal', TagsModal);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, id);
};
const tagsModalContainers = document.querySelectorAll('.vue-tags-modal:not(.initialized)');
tagsModalContainers.forEach((container) => {
$(container).addClass('initialized');
window.initTagsModalComponent(`#${container.id}`);
}
});
app.component('tags-modal', TagsModal);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#tagsModalContainer');

View file

@ -20,7 +20,7 @@
'pointer-events-none !text-sn-grey': !params.urls.show,
'!text-sn-black': dtComponent.currentViewMode === 'archived'
}"
class="font-bold mb-4 shrink-0 text-sn-blue hover:no-underline line-clamp-2 hover:text-sn-blue h-10">
class="font-bold mb-4 shrink-0 text-sn-blue hover:no-underline line-clamp-3 hover:text-sn-blue h-16">
{{ params.name }}
</a>
<div class="flex gap-4 mb-2.5">

View file

@ -1,9 +1,12 @@
<template>
<div class="content-pane flexible with-grey-background">
<div class="content-pane flexible with-grey-background" data-e2e="e2e-CO-globalSearch">
<div class="content-header">
<div class="title-row">
<h1 class="mt-0">
{{ i18n.t('search.index.results_title_html', { query: localQuery }) }}
<h1 class="mt-0 truncate !inline">
<StringWithEllipsis
class="w-full"
:endCharacters="5"
:text="i18n.t('search.index.results_title_html', { query: localQuery })"></StringWithEllipsis>
</h1>
</div>
</div>
@ -11,12 +14,13 @@
<GeneralDropdown ref="historyContainer" :canOpen="canOpenHistory" :fieldOnlyOpen="true" >
<template v-slot:field>
<div class="left-icon sci-input-container-v2 w-72 input-sm"
:title="i18n.t('nav.search')" :class="{'error': invalidQuery}">
:title="i18n.t('nav.search')" :class="{'error': invalidQuery}" :data-e2e="'e2e-IF-globalSearch'">
<input ref="searchField"
type="text"
class="!pr-9"
:value="localQuery"
@change="changeQuery"
@keydown="focusHistoryItem"
@keydown.enter="changeQuery"
@blur="changeQuery"
:placeholder="i18n.t('nav.search')"
@ -24,34 +28,46 @@
<i class="sn-icon sn-icon-search"></i>
<i v-if="localQuery.length > 0"
class="sn-icon cursor-pointer sn-icon-close absolute right-0 -top-0.5"
@click="localQuery = ''; $refs.searchField.focus()"></i>
@click="localQuery = ''; $refs.searchField.focus()" :title="i18n.t('nav.clear')" :data-e2e="'e2e-BT-globalSearch-clearInput'"></i>
</div>
</template>
<template v-slot:flyout >
<div v-for="(query, i) in reversedPreviousQueries" @click="setQuery(query)" :key="i"
ref="historyItems"
tabindex="1"
@keydown.enter="setQuery(query)"
class="flex px-3 h-11 items-center gap-2 hover:bg-sn-super-light-grey cursor-pointer">
<i class="sn-icon sn-icon-history-search"></i>
{{ query }}
<div class="max-w-[600px]">
<div v-for="(query, i) in reversedPreviousQueries" @click="setQuery(query)" :key="i"
ref="historyItems"
@keydown="focusHistoryItem"
tabindex="1"
@keydown.enter="setQuery(query)"
class="flex px-3 min-h-11 items-center gap-2 hover:bg-sn-super-light-grey cursor-pointer">
<i class="sn-icon sn-icon-history-search"></i>
{{ query }}
</div>
</div>
</template>
</GeneralDropdown>
<div class="flex items-center gap-2.5">
<button class="btn btn-secondary btn-sm" :class="{'active': activeGroup == 'ExperimentsComponent'}" @click="setActiveGroup('ExperimentsComponent')">
<button class="btn btn-secondary btn-sm"
:class="{'active': activeGroup == 'ExperimentsComponent'}"
@click="setActiveGroup('ExperimentsComponent')"
:data-e2e="'e2e-BT-globalSearch-experiments'">
{{ i18n.t('search.index.experiments') }}
</button>
<button class="btn btn-secondary btn-sm" :class="{'active': activeGroup == 'MyModulesComponent'}" @click="setActiveGroup('MyModulesComponent')">
<button class="btn btn-secondary btn-sm"
:class="{'active': activeGroup == 'MyModulesComponent'}"
@click="setActiveGroup('MyModulesComponent')"
:data-e2e="'e2e-BT-globalSearch-tasks'">
{{ i18n.t('search.index.tasks') }}
</button>
<button class="btn btn-secondary btn-sm" :class="{'active': activeGroup == 'ResultsComponent'}" @click="setActiveGroup('ResultsComponent')">
<button class="btn btn-secondary btn-sm"
:class="{'active': activeGroup == 'ResultsComponent'}"
@click="setActiveGroup('ResultsComponent')"
:data-e2e="'e2e-BT-globalSearch-taskResults'">
{{ i18n.t('search.index.task_results') }}
</button>
</div>
<button class="btn btn-light btn-sm" @click="filterModalOpened = true">
<i class="sn-icon sn-icon-search-options"></i>
<span class="tw-hidden lg:inline">{{ i18n.t('search.index.more_search_options') }}</span>
<button class="btn btn-light btn-sm" @click="filterModalOpened = true" :data-e2e="'e2e-BT-globalSearch-openFilterModal'">
<i class="sn-icon sn-icon-search-options" :title="i18n.t('search.index.more_search_options')"></i>
<span class="tw-hidden xl:inline">{{ i18n.t('search.index.more_search_options') }}</span>
<span
v-if="activeFilters.length > 0"
class="absolute -right-1 -top-1 rounded-full bg-sn-science-blue text-white flex items-center justify-center w-4 h-4 text-[9px]"
@ -61,14 +77,14 @@
</button>
<template v-if="activeFilters.length > 0">
<div class="h-4 w-[1px] bg-sn-grey"></div>
<button class="btn btn-light btn-sm" @click="resetFilters">
<i class="sn-icon sn-icon-close"></i>
<span class="tw-hidden lg:inline">{{ i18n.t('search.index.clear_filters') }}</span>
<button class="btn btn-light btn-sm" @click="resetFilters" :data-e2e="'e2e-BT-globalSearch-resetFilters'">
<i class="sn-icon sn-icon-close" :title="i18n.t('search.index.clear_filters')"></i>
<span class="tw-hidden xl:inline">{{ i18n.t('search.index.clear_filters') }}</span>
</button>
</template>
<button v-if="activeGroup" class="btn btn-light btn-sm" @click="resetGroup">
<i class="sn-icon sn-icon-undo"></i>
<span class="tw-hidden lg:inline">{{ i18n.t('search.index.all_results') }}</span>
<button v-if="activeGroup" class="btn btn-light btn-sm" @click="resetGroup" :data-e2e="'e2e-BT-globalSearch-resetGroup'">
<i class="sn-icon sn-icon-undo" :title="i18n.t('search.index.all_results')"></i>
<span class="tw-hidden xl:inline">{{ i18n.t('search.index.all_results') }}</span>
</button>
</div>
<template v-for="group in searchGroups">
@ -85,7 +101,7 @@
@updated="calculateTotalElements"
/>
</template>
<div v-if="totalElements === 0" class="bg-white rounded p-4">
<div v-if="totalElements === 0 && activeGroup === null" class="bg-white rounded p-4">
<NoSearchResult />
</div>
<teleport to='body'>
@ -103,6 +119,8 @@
</template>
<script>
/* global HelperModule GLOBAL_CONSTANTS */
import FoldersComponent from './groups/folders.vue';
import ProjectsComponent from './groups/projects.vue';
import ExperimentsComponent from './groups/experiments.vue';
@ -117,6 +135,7 @@ import ReportsComponent from './groups/reports.vue';
import FiltersModal from './filters_modal.vue';
import GeneralDropdown from '../shared/general_dropdown.vue';
import NoSearchResult from './groups/helpers/no_search_result.vue';
import StringWithEllipsis from '../shared/string_with_ellipsis.vue';
export default {
emits: ['search', 'selectGroup'],
@ -141,6 +160,10 @@ export default {
currentTeam: {
type: Number || String,
required: true
},
singleTeam: {
type: Boolean,
default: false
}
},
components: {
@ -157,7 +180,8 @@ export default {
ReportsComponent,
FiltersModal,
GeneralDropdown,
NoSearchResult
NoSearchResult,
StringWithEllipsis
},
data() {
return {
@ -167,6 +191,7 @@ export default {
previousQueries: [],
invalidQuery: false,
activeGroup: null,
focusedHistoryItem: null,
totalElements: 0,
searchGroups: [
'FoldersComponent',
@ -215,7 +240,7 @@ export default {
to: null
},
include_archived: urlParams.get('include_archived') === 'true',
teams: urlParams.getAll('teams[]').map((team) => parseInt(team, 10)),
teams: (this.singleTeam ? [] : urlParams.getAll('teams[]').map((team) => parseInt(team, 10))),
users: urlParams.getAll('users[]').map((user) => parseInt(user, 10)),
group: urlParams.get('group')
};
@ -264,8 +289,15 @@ export default {
this.localQuery = event.target.value;
if (event.target.value.length < 2) {
if (event.target.value.length < GLOBAL_CONSTANTS.NAME_MIN_LENGTH) {
this.invalidQuery = true;
HelperModule.flashAlertMsg(this.i18n.t('general.query.length_too_short', { min_length: GLOBAL_CONSTANTS.NAME_MIN_LENGTH }), 'danger');
return;
}
if (event.target.value.length > GLOBAL_CONSTANTS.NAME_MAX_LENGTH) {
this.invalidQuery = true;
HelperModule.flashAlertMsg(this.i18n.t('general.query.length_too_long', { max_length: GLOBAL_CONSTANTS.NAME_MAX_LENGTH }), 'danger');
return;
}
@ -295,6 +327,28 @@ export default {
this.activeGroup = null;
this.filters.group = null;
},
focusHistoryItem(event) {
if (this.focusedHistoryItem === null && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
this.focusedHistoryItem = 0;
this.$refs.historyItems[this.focusedHistoryItem].focus();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.focusedHistoryItem += 1;
if (this.focusedHistoryItem >= this.$refs.historyItems.length) {
this.focusedHistoryItem = 0;
}
this.$refs.historyItems[this.focusedHistoryItem].focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.focusedHistoryItem -= 1;
if (this.focusedHistoryItem < 0) {
this.focusedHistoryItem = this.$refs.historyItems.length - 1;
}
this.$refs.historyItems[this.focusedHistoryItem].focus();
} else if (event.key === 'Escape') {
this.$refs.historyContainer.isOpen = false;
}
},
resetFilters() {
this.filters = {
created_at: {

View file

@ -1,39 +1,48 @@
<template>
<div class="max-w-[600px] py-3.5">
<div class="flex flex-col pb-6 overflow-y-auto max-h-[75vh]">
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_type') }}</div>
<div class="flex flex-col pb-6 overflow-y-auto w-[calc(100%_+_1rem)] px-2 -ml-2 max-h-[calc(80vh_-_160px)]">
<div class="sci-label mb-2" data-e2e="e2e-TX-globalSearch-filters-filterByType">{{ i18n.t('search.filters.by_type') }}</div>
<div class="flex items-center gap-2 flex-wrap mb-6">
<template v-for="group in searchGroups" :key="group.value">
<button class="btn btn-secondary btn-xs"
ref="groupButtons"
:class="{'active': activeGroup === group.value}"
@click="setActiveGroup(group.value)">
@click="setActiveGroup(group.value)"
:data-e2e="`e2e-BT-globalSearch-filters-${group.label.toLowerCase().replaceAll(/\s+/g, '')}`">
{{ group.label }}
</button>
</template>
</div>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_created_date') }}</div>
<div class="sci-label mb-2" data-e2e="e2e-TX-globalSearch-filters-filterByCreated">{{ i18n.t('search.filters.by_created_date') }}</div>
<DateFilter
:date="createdAt"
ref="createdAtComponent"
class="mb-6"
@change="(v) => {this.createdAt = v}"
e2eValue="globalSearch-filters-createdDate"
></DateFilter>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_updated_date') }}</div>
<div class="sci-label mb-2" data-e2e="e2e-TX-globalSearch-filters-filterByUpdated">{{ i18n.t('search.filters.by_updated_date') }}</div>
<DateFilter
:date="updatedAt"
ref="updatedAtComponent"
class="mb-6"
@change="(v) => {this.updatedAt = v}"
e2eValue="globalSearch-filters-updatedDate"
></DateFilter>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_team') }}</div>
<SelectDropdown :options="teams"
class="mb-6"
:with-checkboxes="true"
:clearable="true"
:multiple="true"
:value="selectedTeams"
@change="(v) => {selectedTeams = v}" />
<div class="sci-label mb-2 flex items-center gap-2">
<template v-if="teams.length > 1">
<div class="sci-label mb-2" data-e2e="e2e-TX-globalSearch-filters-filterByTeam">{{ i18n.t('search.filters.by_team') }}</div>
<SelectDropdown :options="teams"
class="mb-6"
:with-checkboxes="true"
:clearable="true"
:searchable="true"
:multiple="true"
:value="selectedTeams"
:placeholder="i18n.t('search.filters.by_team_placeholder')"
@change="selectTeams"
e2eValue="e2e-DC-globalSearch-filters-teams" />
</template>
<div class="sci-label mb-2 flex items-center gap-2" data-e2e="e2e-TX-globalSearch-filters-filterByUser">
{{ i18n.t('search.filters.by_user') }}
<i class="sn-icon sn-icon-info" :title="i18n.t('search.filters.by_user_info')"></i>
</div>
@ -43,12 +52,15 @@
:optionRenderer="userRenderer"
:labelRenderer="userRenderer"
:clearable="true"
:searchable="true"
:with-checkboxes="true"
:multiple="true"
@change="(v) => {selectedUsers = v}" />
<div class="flex items-center gap-2">
:placeholder="i18n.t('search.filters.by_user_placeholder')"
@change="selectUsers"
e2eValue="e2e-DC-globalSearch-filters-users" />
<div class="flex items-center gap-2" data-e2e="e2e-TX-globalSearch-filters-includeArchived">
<div class="sci-checkbox-container">
<input type="checkbox" v-model="includeArchived" class="sci-checkbox" />
<input type="checkbox" v-model="includeArchived" class="sci-checkbox" data-e2e="e2e-CB-globalSearch-filters-includeArchived"/>
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('search.filters.include_archived') }}
@ -56,9 +68,9 @@
</div>
<hr class="mb-6">
<div class="flex items-center gap-6">
<button class="btn btn-light" @click="clearFilters">{{ i18n.t('search.filters.clear') }}</button>
<button class="btn btn-secondary ml-auto" @click="$emit('cancel')">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="search" >{{ i18n.t('general.search') }}</button>
<button class="btn btn-light" @click="clearFilters" data-e2e="e2e-BT-globalSearch-filters-clearFilters">{{ i18n.t('search.filters.clear') }}</button>
<button class="btn btn-secondary ml-auto" @click="$emit('cancel')" data-e2e="e2e-BT-globalSearch-filters-cancel">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" @click="search" data-e2e="e2e-BT-globalSearch-filters-search">{{ i18n.t('general.search') }}</button>
</div>
</div>
</template>
@ -171,6 +183,16 @@ export default {
this.users = response.data.data.map((user) => ([parseInt(user.id, 10), user.attributes.name, { avatar_url: user.attributes.avatar_url }]));
});
},
selectTeams(teams) {
if (Array.isArray(teams)) {
this.selectedTeams = teams;
}
},
selectUsers(users) {
if (Array.isArray(users)) {
this.selectedUsers = users;
}
},
clearFilters() {
this.createdAt = {
on: null,

View file

@ -3,6 +3,7 @@
<SelectDropdown class="!w-40"
:options="dateOptions"
:value="selectedOption"
:data-e2e="`e2e-DD-${e2eValue}`"
@change="(v) => {selectedOption = v}" />
<div class="grow">
<DateTimePicker
@ -12,7 +13,8 @@
size="mb"
placeholder="Enter date"
:defaultValue="date.on"
:clearable="true"/>
:clearable="true"
:data-e2e="`e2e-DP-${e2eValue}`"/>
<DateTimePicker
v-if="selectedOption === 'custom'"
@change="setFrom"
@ -21,7 +23,8 @@
size="mb"
placeholder="From date"
:defaultValue="date.from"
:clearable="true"/>
:clearable="true"
:data-e2e="`e2e-DP-${e2eValue}From`"/>
<DateTimePicker
v-if="selectedOption === 'custom'"
@change="setTo"
@ -29,7 +32,8 @@
size="mb"
placeholder="To date"
:defaultValue="date.to"
:clearable="true"/>
:clearable="true"
:data-e2e="`e2e-DP-${e2eValue}To`"/>
</div>
</div>
</template>
@ -44,6 +48,10 @@ export default {
date: {
type: Object,
required: true
},
e2eValue: {
type: String,
default: ''
}
},
components: {

View file

@ -2,15 +2,15 @@
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content !pb-2.5">
<div class="modal-content !pb-2.5" data-e2e="e2e-MD-globalSearch-filters">
<div class="modal-header flex-wrap">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" data-e2e="e2e-BT-globalSearch-filters-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label">
<h4 class="modal-title truncate !block" id="edit-project-modal-label" data-e2e="e2e-TX-globalSearch-filters-title">
{{ i18n.t('search.filters.title') }}
</h4>
<div class="basis-full">
<div class="basis-full" data-e2e="e2e-TX-globalSearch-filters-subtitle">
{{ i18n.t('search.filters.sub_title') }}
</div>
</div>

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-files'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,16 @@
{{ i18n.t('search.index.files') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-files-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_auto_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.created_at'),
i18n.t('search.index.updated_at'),
'',
'',
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-6 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.parent.url" :icon="row.attributes.icon" :value="row.attributes.file_name"/>
@ -26,7 +33,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'AssetsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'AssetsComponent')" :data-e2e="'e2e-BT-globalSearch-files-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-experiments'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,15 @@
{{ i18n.t('search.index.experiments') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-experiments-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.project'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-5 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
@ -21,7 +27,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'ExperimentsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'ExperimentsComponent')" :data-e2e="'e2e-BT-globalSearch-experiments-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-folders'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,12 +7,19 @@
{{ i18n.t('search.index.folders') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-folders-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_auto_auto_auto] items-center">
<div class="grid grid-cols-[auto_110px_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.folder'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-4 w-full m-0" v-if="index > 0">
<hr class="col-span-5 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
<CellTemplate :label="i18n.t('search.index.id')" :value="row.attributes.code"/>
<CellTemplate :label="i18n.t('search.index.created_at')" :value="row.attributes.created_at"/>
<CellTemplate :label="i18n.t('search.index.folder')" :visible="row.attributes.parent_folder"
:url="row.attributes.parent_folder?.url" :value="labelName(row.attributes.parent_folder)"/>
@ -21,7 +28,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'FoldersComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'FoldersComponent')" :data-e2e="'e2e-BT-globalSearch-folders-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,7 +1,7 @@
<template>
<div class="h-full py-2 px-4 grid grid-cols-[auto_1fr] min-w-[8rem] items-center gap-1 text-xs group-hover:bg-sn-super-light-grey">
<div class="h-full py-2 px-4 grid grid-cols-[auto_1fr] min-w-[6rem] items-center gap-1 text-xs group-hover:bg-sn-super-light-grey">
<template v-if="visible">
<b class="shrink-0">{{ label }}:</b>
<b class="shrink-0 tw-hidden lg:block">{{ label }}:</b>
<a v-if="url" :href="url" class="shrink-0 overflow-hidden hover:no-underline">
<img v-if="avatar" :src="avatar" class="w-5 h-5 border border-sn-super-light-grey rounded-full mx-1" />
<StringWithEllipsis class="w-full" :text="value"></StringWithEllipsis>

View file

@ -1,6 +1,6 @@
<template>
<a target="_blank" :href="url"
class="h-full py-2 px-4 flex gap-1 items-center font-bold overflow-hidden group-hover:bg-sn-super-light-grey hover:no-underline"
class="h-full py-2 px-4 flex gap-1 items-center font-bold overflow-hidden group-hover:bg-sn-super-light-grey min-w-40 hover:no-underline"
>
<span v-if="icon" :class="icon" class="sn-icon shrink-0"></span>
<StringWithEllipsis

View file

@ -1,7 +1,7 @@
<template>
<div ref="noSearchResult" class="h-[60vh]">
<div class="flex flex-col gap-6 bg-sn-white text-center relative top-1/4">
<div><span class=" inline-block sn-icon sn-icon-search"></span></div>
<div><span class=" inline-block sn-icon sn-icon-search text-sn-sleepy-grey" style="font-size: 100px !important"></span></div>
<div class="">
<p class="text-sn-black text-2xl font-semibold">
{{ i18n.t('search.index.no_results_text') }}

View file

@ -6,6 +6,7 @@
position="right"
@dtEvent="changeSort"
btnIcon="sn-icon sn-icon-sort-down"
:e2eSortButton="e2eSortButton"
></MenuDropdown>
</template>
@ -18,6 +19,10 @@ export default {
sort: {
type: String,
default: 'created_desc'
},
e2eSortButton: {
type: String,
default: ''
}
},
components: {

View file

@ -0,0 +1,27 @@
<template>
<div class="contents lg:hidden">
<div class="px-4 py-2 bg-white h-full" :class="{'sticky top-[68px] z-10': selected}"></div>
<div
v-for="column in columnNames"
:key="column"
class="truncate px-4 py-2 bg-white text-xs font-bold h-full"
:class="{'sticky top-[68px] z-10': selected}"
>{{ column }}</div>
</div>
</template>
<script>
export default {
name: 'TableHeader',
props: {
columnNames: {
type: Array,
required: true
},
selected: {
type: Boolean,
default: false
}
}
};
</script>

View file

@ -1,5 +1,5 @@
<template>
<div v-if="!disabled" ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div v-if="!disabled" ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-labelTemplates'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,16 @@
{{ i18n.t('search.index.label_templates') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-labelTemplates-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.format'),
i18n.t('search.index.created_at'),
i18n.t('search.index.updated_at'),
i18n.t('search.index.created_by'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-6 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="row.attributes.name"/>
@ -22,7 +29,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'LabelTemplatesComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'LabelTemplatesComponent')" :data-e2e="'e2e-BT-globalSearch-labelTemplates-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-taskProtocols'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,17 @@
{{ i18n.t('search.index.task_protocols') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-taskProtocols-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.updated_at'),
i18n.t('search.index.task'),
i18n.t('search.index.experiment'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-7 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
@ -23,7 +31,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'MyModuleProtocolsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'MyModuleProtocolsComponent')" :data-e2e="'e2e-BT-globalSearch-taskProtocols-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-tasks'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,16 @@
{{ i18n.t('search.index.tasks') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-tasks-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.updated_at'),
i18n.t('search.index.experiment'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-6 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
@ -22,7 +29,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'MyModulesComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'MyModulesComponent')" :data-e2e="'e2e-BT-globalSearch-tasks-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-projects'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,15 @@
{{ i18n.t('search.index.projects') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-projects-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.folder'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-5 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
@ -22,7 +28,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'ProjectsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'ProjectsComponent')" :data-e2e="'e2e-BT-globalSearch-projects-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-protocolTemplates'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,16 @@
{{ i18n.t('search.index.protocol_templates') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-protocolTemplates-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.updated_at'),
i18n.t('search.index.created_by'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-6 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
@ -22,7 +29,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'ProtocolsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'ProtocolsComponent')" :data-e2e="'e2e-BT-globalSearch-protocolTemplates-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-reports'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,17 @@
{{ i18n.t('search.index.reports') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-reports-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.updated_at'),
i18n.t('search.index.created_by'),
i18n.t('search.index.project'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-7 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="row.attributes.name"/>
@ -23,7 +31,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'ReportsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'ReportsComponent')" :data-e2e="'e2e-BT-globalSearch-reports-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-inventoryItems'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,16 @@
{{ i18n.t('search.index.inventory_items') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-inventoryItems-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.id'),
i18n.t('search.index.created_at'),
i18n.t('search.index.created_by'),
i18n.t('search.index.repository'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-6 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
@ -22,7 +29,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'RepositoryRowsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'RepositoryRowsComponent')" :data-e2e="'e2e-BT-globalSearch-inventoryItems-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -1,5 +1,5 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }" :data-e2e="'e2e-CO-globalSearch-results'">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
@ -7,9 +7,16 @@
{{ i18n.t('search.index.task_results') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort" :e2eSortButton="'e2e-BT-globalSearch-results-sort'"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_auto_auto_auto_auto_auto] items-center">
<TableHeader :selected="selected" :columnNames="[
i18n.t('search.index.created_at'),
i18n.t('search.index.updated_at'),
i18n.t('search.index.task'),
i18n.t('search.index.experiment'),
i18n.t('search.index.team')
]"></TableHeader>
<div v-for="(row, index) in preparedResults" :key="row.id" class="contents group">
<hr class="col-span-6 w-full m-0" v-if="index > 0">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
@ -22,7 +29,7 @@
</div>
<div v-if="viewAll">
<hr class="w-full mb-4 mt-0">
<button class="btn btn-light" @click="$emit('selectGroup', 'ResultsComponent')">View all</button>
<button class="btn btn-light" @click="$emit('selectGroup', 'ResultsComponent')" :data-e2e="'e2e-BT-globalSearch-results-viewAll'">View all</button>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />

View file

@ -6,6 +6,7 @@ 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';
import TableHeader from './helpers/table_header.vue';
/* global GLOBAL_CONSTANTS I18n */
export default {
@ -22,7 +23,8 @@ export default {
NoSearchResult,
ListEnd,
CellTemplate,
LinkTemplate
LinkTemplate,
TableHeader
},
data() {
return {
@ -86,11 +88,13 @@ export default {
handleScroll() {
if (this.loading || !this.selected) return;
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
if (this.results.length < this.total) {
this.loadData();
this.$nextTick(() => {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 50) {
if (this.results.length < this.total) {
this.loadData();
}
}
}
});
},
changeSort(sort) {
this.sort = sort;
@ -110,8 +114,10 @@ export default {
loadData() {
if (this.query.length < 2) return;
if (this.loading && this.page) return;
if (this.loading && this.page && !(this.selected && !this.fullDataLoaded)) return;
const fullView = this.selected;
const currentPage = this.page;
this.loading = true;
axios.get(this.searchUrl, {
params: {
@ -119,21 +125,28 @@ export default {
sort: this.sort,
filters: this.filters,
group: this.group,
preview: !this.selected,
page: this.page
preview: !fullView,
page: currentPage
}
})
.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;
if (this.selected === fullView && this.page === currentPage) {
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;
this.handleScroll();
}
})
.finally(() => {
this.loading = false;
this.$emit('updated');
if (this.selected === fullView) {
this.loading = false;
this.$emit('updated');
}
});
}
}

View file

@ -274,6 +274,7 @@ export default {
this.reloadingTable = true;
this.editModalObject = null;
this.moveModalObject = null;
this.updateNavigator(true);
},
updateNavigator(withExpanedChildren = false) {
window.navigatorContainer.reloadNavigator(withExpanedChildren);
@ -302,12 +303,14 @@ export default {
edit(_e, rows) {
[this.editModalObject] = rows;
},
move(_e, rows) {
move(event, rows) {
[this.moveModalObject] = rows;
this.moveModalObject.movePath = event.path;
},
duplicate(event, rows) {
axios.post(event.path, { my_module_ids: rows.map((row) => row.id) }).then(() => {
this.reloadingTable = true;
this.updateNavigator(true);
}).catch((error) => {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});

View file

@ -52,9 +52,8 @@ export default {
},
methods: {
submit() {
axios.post(this.my_module.urls.move, {
to_experiment_id: this.targetExperiment,
my_module_ids: [this.my_module.id]
axios.post(this.my_module.movePath, {
to_experiment_id: this.targetExperiment
}).then((response) => {
this.$emit('move');
HelperModule.flashAlertMsg(response.data.message, 'success');

View file

@ -105,7 +105,7 @@ export default {
},
computed: {
validName() {
return this.name.length > 0;
return this.name.length > 1;
},
formattedTags() {
return this.allTags.map((tag) => (

View file

@ -20,7 +20,7 @@
<div class="mb-4">
<h5>{{ i18n.t("experiments.canvas.modal_manage_tags.project_tags", { project: this.projectName }) }}</h5>
</div>
<div class="max-h-80 overflow-y-auto" v-click-outside="finishEditMode">
<div ref="scrollContainer" class="max-h-80 overflow-y-auto" v-click-outside="finishEditMode">
<template v-for="tag in sortedAllTags" :key="tag.id">
<div
class="flex items-center gap-3 px-3 py-2.5 group"
@ -203,6 +203,8 @@ export default {
startEditMode(tag) {
if (!this.canManage) return;
const scrollPosition = this.$refs.scrollContainer.scrollTop;
this.finishEditMode();
tag.initialName = tag.attributes.name;
@ -210,6 +212,7 @@ export default {
this.tagToUpdate = tag;
this.$nextTick(() => {
this.$refs.modal.querySelector('input').focus();
this.$refs.scrollContainer.scrollTop = scrollPosition;
});
},
finishEditMode(e, tag = null) {
@ -316,6 +319,7 @@ export default {
this.creatingTag = true;
this.$nextTick(() => {
this.$refs.newTagNameInput.focus();
this.setRandomColor();
});
},
cancelCreating(e) {
@ -323,6 +327,11 @@ export default {
this.creatingTag = false;
this.newTag = { name: null, color: null };
},
setRandomColor() {
if (!this.newTag.color) {
this.newTag.color = this.tagsColors[Math.floor(Math.random() * this.tagsColors.length)];
}
}
}
};

View file

@ -1,25 +1,31 @@
<template>
<GeneralDropdown ref="container" :canOpen="canOpen" :fieldOnlyOpen="true" @close="filtersOpened = false; flyoutOpened = false" @open="flyoutOpened = true">
<template v-slot:field>
<div class="sci--navigation--top-menu-search left-icon sci-input-container-v2" :class="{'disabled' : !currentTeam}" :title="i18n.t('nav.search')">
<input ref="searchField" type="text" class="!pr-20" v-model="searchQuery" @keydown="focusHistoryItem"
<div class="sci--navigation--top-menu-search left-icon sci-input-container-v2"
:class="{'disabled' : !currentTeam, 'error': invalidQuery}" :title="i18n.t('nav.search')"
:data-e2e="'e2e-IF-topMenu-search'">
<input ref="searchField" type="text" class="!pr-20" v-model="searchQuery"
:class="{'active': flyoutOpened}"
@focus="openHistory" :placeholder="i18n.t('nav.search')" @keyup.enter="saveQuery"/>
@keydown="focusHistoryItem"
@keydown.down="focusQuickSearchResults"
@keydown.escape="closeFlyout"
@focus="openHistory" :placeholder="i18n.t('nav.search')" @keydown.enter="saveQuery"/>
<i class="sn-icon sn-icon-search"></i>
<div v-if="this.searchQuery.length > 1" class="flex items-center gap-1 absolute right-2 top-1.5">
<div class="btn btn-light icon-btn btn-xs" @click="this.searchQuery = ''; $refs.searchField.focus()">
<i class="sn-icon sn-icon-close m-0"></i>
</div>
<div class="btn btn-light icon-btn btn-xs" :title="i18n.t('search.quick_search.search_options')"
:class="{'active': filtersOpened}" @click="filtersOpened = !filtersOpened">
<i class="sn-icon sn-icon-search-options m-0"></i>
</div>
<button class="btn btn-light icon-btn btn-xs" @click="this.searchQuery = ''; $refs.searchField.focus()" :data-e2e="'e2e-BT-topMenu-searchClear'">
<i class="sn-icon sn-icon-close !m-0" :title="i18n.t('nav.clear')"></i>
</button>
<button class="btn btn-light icon-btn btn-xs" :title="i18n.t('search.quick_search.search_options')"
:class="{'active': filtersOpened}" @click="filtersOpened = !filtersOpened" :data-e2e="'e2e-BT-topMenu-searchFilters'">
<i class="sn-icon sn-icon-search-options !m-0"></i>
</button>
</div>
</div>
</template>
<template v-slot:flyout >
<SearchFilters
class="px-3.5"
ref="filters"
v-if="filtersOpened"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
@ -28,39 +34,43 @@
:searchQuery="searchQuery"
@cancel="filtersOpened = false"
></SearchFilters>
<div v-else-if="showHistory" class="max-w-[600px]">
<div v-else-if="showHistory" class="max-w-[600px]" data-e2e="e2e-DD-topMenu-searchHistory">
<div v-for="(query, i) in reversedPreviousQueries" @click="setQuery(query)" :key="i"
ref="historyItems"
tabindex="1"
@keydown="focusHistoryItem"
@keydown.enter="setQuery(query)"
class="flex px-3 h-11 items-center gap-2 hover:bg-sn-super-light-grey cursor-pointer">
class="flex px-3 min-h-11 items-center gap-2 hover:bg-sn-super-light-grey cursor-pointer">
<i class="sn-icon sn-icon-history-search"></i>
{{ query }}
</div>
</div>
<div v-else class="w-[600px]">
<div v-else class="w-[600px]" data-e2e="e2e-FO-topMenu-quickSearch">
<div class="flex items-center gap-2">
<button class="btn btn-secondary btn-xs"
ref="experimentGroup"
:class="{'active': quickFilter === 'experiments'}"
@click="setQuickFilter('experiments')">
@click="setQuickFilter('experiments')"
:data-e2e="'e2e-BT-topMenu-quickSearch-experiments'">
{{ i18n.t('search.quick_search.experiments') }}
</button>
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'my_modules'}"
@click="setQuickFilter('my_modules')">
@click="setQuickFilter('my_modules')"
:data-e2e="'e2e-BT-topMenu-quickSearch-tasks'">
{{ i18n.t('search.quick_search.tasks') }}
</button>
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'results'}"
@click="setQuickFilter('results')">
@click="setQuickFilter('results')"
:data-e2e="'e2e-BT-topMenu-quickSearch-taskResults'">
{{ i18n.t('search.quick_search.results') }}
</button>
</div>
<hr class="my-2">
<a v-if="!loading" v-for="(result, i) in results" :key="i"
:href="getUrl(result.attributes)"
class="px-3 py-2 hover:bg-sn-super-light-grey cursor-pointer
class="px-3 py-2 hover:bg-sn-super-light-grey cursor-pointer focus:no-underline
text-sn-black hover:no-underline active:no-underline hover:text-black block"
>
<div class="flex items-center gap-2">
@ -103,15 +113,17 @@
</div>
</div>
<hr class="my-2">
<div class="btn btn-light" @click="searchValue">
<button class="btn btn-light truncate !block leading-10 max-w-[600px]" @click="searchValue" :data-e2e="'e2e-BT-topMenu-quickSearch-allSearchResults'">
{{ i18n.t('search.quick_search.all_results', {query: searchQuery}) }}
</div>
</button>
</div>
</template>
</GeneralDropdown>
</template>
<script>
/* global HelperModule GLOBAL_CONSTANTS */
import GeneralDropdown from '../shared/general_dropdown.vue';
import StringWithEllipsis from '../shared/string_with_ellipsis.vue';
import SearchFilters from '../global_search/filters.vue';
@ -157,6 +169,9 @@ export default {
},
currentTeamName() {
return document.querySelector('body').dataset.currentTeamName;
},
invalidQuery() {
return this.searchQuery.length > GLOBAL_CONSTANTS.NAME_MAX_LENGTH;
}
},
watch: {
@ -166,6 +181,9 @@ export default {
if (this.searchQuery.length > 1) {
this.fetchQuickSearchResults();
}
},
filtersOpened() {
if (this.filtersOpened) this.$nextTick(() => { this.focusFilters() });
}
},
data() {
@ -282,7 +300,7 @@ export default {
this.fetchQuickSearchResults();
},
fetchQuickSearchResults() {
if (this.loading) return;
if (this.loading || this.invalidQuery) return;
this.loading = true;
@ -305,27 +323,46 @@ export default {
});
},
searchValue() {
window.open(`${this.searchUrl}?q=${this.searchQuery}&teams[]=${this.currentTeam}`, '_self');
if (this.searchQuery.length > GLOBAL_CONSTANTS.NAME_MAX_LENGTH) {
HelperModule.flashAlertMsg(this.i18n.t('general.query.length_too_long', { max_length: GLOBAL_CONSTANTS.NAME_MAX_LENGTH }), 'danger');
return;
}
window.open(`${this.searchUrl}?q=${this.searchQuery}&teams[]=${this.currentTeam}&include_archived=true`, '_self');
},
focusHistoryItem(event) {
if (this.focusedHistoryItem === null && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {
this.focusedHistoryItem = 0;
this.$refs.historyItems[this.focusedHistoryItem].focus();
this.$refs.historyItems[this.focusedHistoryItem]?.focus();
} else if (event.key === 'ArrowDown') {
event.preventDefault();
this.focusedHistoryItem += 1;
if (this.focusedHistoryItem >= this.$refs.historyItems.length) {
this.focusedHistoryItem = 0;
}
this.$refs.historyItems[this.focusedHistoryItem].focus();
this.$refs.historyItems[this.focusedHistoryItem]?.focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this.focusedHistoryItem -= 1;
if (this.focusedHistoryItem < 0) {
this.focusedHistoryItem = this.$refs.historyItems.length - 1;
}
this.$refs.historyItems[this.focusedHistoryItem].focus();
this.$refs.historyItems[this.focusedHistoryItem]?.focus();
}
},
focusQuickSearchResults(e) {
this.$refs.experimentGroup?.focus();
e.preventDefault();
},
focusFilters() {
const { filters } = this.$refs;
if (filters) {
filters.$refs.groupButtons[0].focus();
}
},
closeFlyout() {
this.$refs.container.isOpen = false;
}
}
};

View file

@ -9,6 +9,7 @@
</div>
<QuickSearch
v-if="user"
:key="globalSearchKey"
:class="{'hidden': hideSearch}"
:quickSearchUrl="quickSearchUrl"
:searchUrl="searchUrl"
@ -109,6 +110,7 @@ export default {
userMenu: null,
unseenNotificationsCount: 0,
hideSearch: false,
globalSearchKey: 0
};
},
created() {
@ -120,6 +122,7 @@ export default {
this.checkUnseenNotifications();
this.refreshCurrentTeam();
this.hideSearch = !!document.getElementById('GlobalSearch');
this.globalSearchKey += 1;
});
// Track name update in user profile settings

View file

@ -43,6 +43,7 @@
:confirmText="i18n.t('projects.export_projects.export_button')"
ref="exportModal"
></ConfirmationModal>
<ExportLimitExceededModal v-if="exportLimitExceded" :description="exportDescription" @close="exportLimitExceded = false"/>
<EditProjectModal v-if="editProject" :userRolesUrl="userRolesUrl"
:project="editProject" @close="editProject = null" @update="updateTable(); updateNavigator()" />
<EditFolderModal v-if="editFolder" :folder="editFolder"
@ -76,6 +77,7 @@ import NewProjectModal from './modals/new.vue';
import NewFolderModal from './modals/new_folder.vue';
import MoveModal from './modals/move.vue';
import AccessModal from '../shared/access_modal/modal.vue';
import ExportLimitExceededModal from './modals/export_limit_exceeded_modal.vue';
export default {
name: 'ProjectsList',
@ -89,7 +91,8 @@ export default {
NewProjectModal,
NewFolderModal,
MoveModal,
AccessModal
AccessModal,
ExportLimitExceededModal
},
props: {
dataSource: { type: String, required: true },
@ -114,6 +117,7 @@ export default {
editFolder: null,
objectsToMove: null,
reloadingTable: false,
exportLimitExceded: false,
folderDeleteDescription: '',
exportDescription: ''
};
@ -306,6 +310,7 @@ export default {
this.newFolder = false;
this.objectsToMove = null;
this.reloadingTable = true;
this.exportLimitExceded = false;
},
updateNavigator(withExpanedChildren = false) {
window.navigatorContainer.reloadNavigator(withExpanedChildren);
@ -328,7 +333,7 @@ export default {
async exportProjects(event, rows) {
if (event.number_of_projects === 0) {
HelperModule.flashAlertMsg(this.i18n.t('projects.export_projects.zero_projects_flash'), 'danger');
} else {
} else if (event.number_of_request_left > 0) {
this.exportDescription = event.message;
const ok = await this.$refs.exportModal.show();
if (ok) {
@ -342,6 +347,9 @@ export default {
HelperModule.flashAlertMsg(error.response.data.error, 'danger');
});
}
} else {
this.exportDescription = event.message;
this.exportLimitExceded = true;
}
},
move(event, rows) {

View file

@ -0,0 +1,36 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label">
{{ i18n.t('repositories.index.modal_export_limit_exceeded.title') }}
</h4>
</div>
<div class="modal-body" v-html="description"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.close') }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
import modalMixin from '../../shared/modal_mixin';
export default {
name: 'ExportLimitExceededModal',
props: {
description: {
type: String,
required: true
}
},
mixins: [modalMixin]
};
</script>

View file

@ -2,28 +2,30 @@
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-content" data-e2e="e2e-MD-renameInventory">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" data-e2e="e2e-BT-renameInventoryModal-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block" id="edit-project-modal-label" :title="repository.name">
<h4 class="modal-title truncate !block" id="edit-project-modal-label" :title="repository.name" data-e2e="e2e-TX-renameInventoryModal-title">
{{ i18n.t('repositories.index.modal_rename.title_html', {name: repository.name }) }}
</h4>
</div>
<div class="modal-body">
<div class="mb-6">
<label class="sci-label">{{ i18n.t("repositories.index.modal_rename.name") }}</label>
<label class="sci-label" data-e2e="e2e-TX-renameInventoryModal-inputLabel">{{ i18n.t("repositories.index.modal_rename.name") }}</label>
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name" class="sci-input-field"
<input type="text" v-model="name"
class="sci-input-field"
autofocus="true" ref="input"
data-e2e="e2e-IF-renameInventoryModal-name"
:placeholder="i18n.t('repositories.index.modal_rename.name_placeholder')" />
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" type="submit">
<button type="button" class="btn btn-secondary" data-dismiss="modal" data-e2e="e2e-BT-renameInventoryModal-cancel">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" type="submit" data-e2e="e2e-BT-renameInventoryModal-save">
{{ i18n.t('repositories.index.modal_rename.rename') }}
</button>
</div>

View file

@ -26,6 +26,7 @@
confirmClass="btn btn-danger"
:confirmText="i18n.t('repositories.index.modal_delete.delete')"
ref="deleteModal"
:e2eAttributes="deleteModal.e2eAttributes"
></ConfirmationModal>
<ExportRepositoryModal
v-if="exportRepository"
@ -114,6 +115,17 @@ export default {
shareRepository: null,
exportAction: null,
deleteModal: {
title: '',
description: '',
e2eAttributes: {
modalName: '',
title: '',
close: '',
cancel: '',
confirm: ''
}
},
exportModal: {
title: '',
description: ''
}
@ -220,10 +232,17 @@ export default {
},
async deleteRepository(event, rows) {
const [repository] = rows;
this.deleteModal.e2eAttributes = {
modalName: 'e2e-MD-deleteInventory',
title: 'e2e-TX-deleteInventoryModal-title',
close: 'e2e-BT-deleteInventoryModal-close',
cancel: 'e2e-BT-deleteInventoryModal-cancel',
confirm: 'e2e-BT-deleteInventoryModal-delete'
};
this.deleteModal.title = this.i18n.t('repositories.index.modal_delete.title_html', { name: repository.name });
this.deleteModal.description = `
<p>${this.i18n.t('repositories.index.modal_delete.message_html', { name: repository.name })}</p>
<div class="alert alert-danger" role="alert">
<p data-e2e="e2e-TX-deleteInventoryModal-info">${this.i18n.t('repositories.index.modal_delete.message_html', { name: repository.name })}</p>
<div class="alert alert-danger" role="alert" data-e2e="e2e-TX-deleteInventoryModal-warning">
<span class="fas fa-exclamation-triangle"></span>
${this.i18n.t('repositories.index.modal_delete.alert_heading')}
<ul>

View file

@ -27,6 +27,7 @@
type="text"
name="from"
v-model="from"
:data-e2e="`e2e-IF-invInventoryFilterCO-inputFrom${this.filter.column.id}`"
:placeholder= "this.i18n.t('repositories.show.repository_filter.filters.types.RepositoryStockValue.from_placeholder')"
/>
</div>
@ -37,6 +38,7 @@
type="text"
name="to"
v-model="to"
:data-e2e="`e2e-IF-invInventoryFilterCO-inputTo${this.filter.column.id}`"
:placeholder= "this.i18n.t('repositories.show.repository_filter.filters.types.RepositoryStockValue.to_placeholder')"
/>
</div>
@ -48,7 +50,7 @@
:selectedValue="this.stock_unit"
:options="this.prepareUnitOptions()"
:selectorId="`StockUnitSelector${this.filter.id}`"
:data-e2e="`e2e-DD-invInventoryFilterCO-input${this.filter.column.id}`"
:data-e2e="`e2e-DD-invInventoryFilterCO-unit${this.filter.column.id}`"
@dropdown:changed="updateStockUnit"
/>
</div>

View file

@ -460,6 +460,8 @@ export default {
},
created() {
window.repositoryItemSidebarComponent = this;
this.handleItemLandingPageLink();
},
computed: {
repositoryRowName() {
@ -686,6 +688,12 @@ export default {
} finally {
this.selectedToUnlink = null;
}
},
handleItemLandingPageLink() {
const itemLandingPageLink = document.getElementById('itemLandingPagelink');
if (itemLandingPageLink) {
this.toggleShowHideSidebar(itemLandingPageLink.getAttribute('href'));
}
}
}
};

View file

@ -255,6 +255,7 @@ export default {
});
},
initZebraPrinter() {
this.printers = this.printers.filter((printer) => !printer.id.startsWith('zebra'));
this.zebraPrinters = zebraPrint.init($('#LabelPrinterSelector'), {
clearSelectorOnFirstDevice: false,
appendDevice: (device) => {

View file

@ -1,76 +1,80 @@
<template>
<div ref="modal" @keydown.esc="cancel" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-dialog" role="document" :data-e2e="e2eAttributes.modalName">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title">
<button type="button" class="close" data-dismiss="modal" aria-label="Close" :data-e2e="e2eAttributes.close"><i class="sn-icon sn-icon-close"></i></button>
<h4 class="modal-title" :data-e2e="e2eAttributes.title">
{{ title }}
</h4>
</div>
<div class="modal-body" v-html="description"></div>
<div class="modal-footer">
<button :class="cancelClass" @click="cancel">{{ cancelText || i18n.t('general.cancel') }}</button>
<button :class="confirmClass" @click="confirm">{{ confirmText || i18n.t('general.confirm') }}</button>
<button :class="cancelClass" @click="cancel" :data-e2e="e2eAttributes.cancel">{{ cancelText || i18n.t('general.cancel') }}</button>
<button :class="confirmClass" @click="confirm" :data-e2e="e2eAttributes.confirm">{{ confirmText || i18n.t('general.confirm') }}</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'confirmationModal',
props: {
title: {
type: String,
required: true
},
description: {
type: String,
required: true
},
cancelText: {
type: String
},
cancelClass: {
type: String,
default: 'btn btn-secondary'
},
confirmText: {
type: String
},
confirmClass: {
type: String,
default: 'btn btn-primary'
}
<script>
export default {
name: 'confirmationModal',
props: {
title: {
type: String,
required: true
},
mounted() {
$(this.$refs.modal).on('hidden.bs.modal', () => {
this.resolvePromise(false)
})
description: {
type: String,
required: true
},
data() {
return {
resolvePromise: null,
rejectPromise: null
}
cancelText: {
type: String
},
methods: {
show: function() {
$(this.$refs.modal).modal('show');
return new Promise((resolve, reject) => {
this.resolvePromise = resolve
this.rejectPromise = reject
})
},
confirm() {
this.resolvePromise(true)
$(this.$refs.modal).modal('hide');
},
cancel() {
this.resolvePromise(false)
$(this.$refs.modal).modal('hide');
}
cancelClass: {
type: String,
default: 'btn btn-secondary'
},
confirmText: {
type: String
},
confirmClass: {
type: String,
default: 'btn btn-primary'
},
e2eAttributes: {
type: Object,
default: () => ({})
}
},
mounted() {
$(this.$refs.modal).on('hidden.bs.modal', () => {
this.resolvePromise(false);
});
},
data() {
return {
resolvePromise: null,
rejectPromise: null
};
},
methods: {
show() {
$(this.$refs.modal).modal('show');
return new Promise((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
},
confirm() {
this.resolvePromise(true);
$(this.$refs.modal).modal('hide');
},
cancel() {
this.resolvePromise(false);
$(this.$refs.modal).modal('hide');
}
}
};
</script>

View file

@ -44,8 +44,7 @@
<Teleport to="body">
<RenameAttachmentModal
v-if="renameModal"
:url_path="attachment.attributes.urls.rename"
:fileName="attachment.attributes.file_name"
:attachment="attachment"
@attachment:update="$emit('attachment:update', $event)"
@close="renameModal = false"
/>

View file

@ -30,41 +30,14 @@
</span>
</div>
</div>
<div class="inline-attachment-action-buttons">
<!-- open -->
<OpenMenu
:attachment="attachment"
:multipleOpenOptions="multipleOpenOptions"
@menu-dropdown-toggle="toggleMenuDropdown"
>
</OpenMenu>
<!-- move -->
<a v-if="attachment.attributes.urls.move"
@click.prevent.stop="showMoveModal"
class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.move')">
<i class="sn-icon sn-icon-move"></i>
</a>
<!-- download -->
<a class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.download')"
:href="attachment.attributes.urls.download" data-turbolinks="false">
<i class="sn-icon sn-icon-export"></i>
</a>
<!-- more options -->
<ContextMenu
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:update="$emit('attachment:update', $event)"
@menu-toggle="toggleContextMenu"
/>
</div>
<ContextMenu
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:update="$emit('attachment:update', $event)"
/>
</div>
<template v-if="attachment.attributes.wopi">
<div v-if="showWopi"

View file

@ -1,73 +1,42 @@
<template>
<div class="list-attachment-container asset hover:bg-sn-super-light-grey"
:class="[{'menu-dropdown-open': isMenuDropdownOpen}, {'context-menu-open': isContextMenuOpen }]"
:data-asset-id="attachment.id">
<div id="icon-with-filename" class="h-6 my-auto">
<i class="text-sn-grey asset-icon sn-icon mb-1" :class="attachment.attributes.icon"></i>
<a :href="attachment.attributes.urls.blob"
class="file-preview-link file-name"
:id="`modal_link${attachment.id}`"
data-no-turbolink="true"
:data-id="attachment.id"
:data-gallery-view-id="parentId"
:data-preview-url="attachment.attributes.urls.preview"
>
<span class="attachment-name" data-toggle="tooltip"
data-placement="bottom">
{{ attachment.attributes.file_name }}
</span>
</a>
<div v-if="attachment.attributes.medium_preview !== null" class="attachment-image-tooltip bg-white sn-shadow-menu-sm">
<img :src="this.imageLoadError ? attachment.attributes.urls.blob : attachment.attributes.medium_preview" @error="ActiveStoragePreviews.reCheckPreview"
@load="ActiveStoragePreviews.showPreview"/>
</div>
</div>
<div id="file-metadata" class="file-metadata">
<span class="my-auto">
{{ attachment.attributes.updated_at_formatted }}
</span>
<span class="my-auto">
{{ attachment.attributes.file_size_formatted }}
</span>
</div>
<div class="flex flex-row" id="action-buttons">
<!-- open -->
<OpenMenu
:attachment="attachment"
:multipleOpenOptions="multipleOpenOptions"
@menu-dropdown-toggle="toggleMenuDropdown"
>
</OpenMenu>
<!-- move -->
<a v-if="attachment.attributes.urls.move"
@click.prevent.stop="showMoveModal"
class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.move')">
<i class="sn-icon sn-icon-move"></i>
</a>
<!-- download -->
<a class="btn btn-light icon-btn thumbnail-action-btn"
:title="i18n.t('attachments.thumbnail.buttons.download')"
:href="attachment.attributes.urls.download" data-turbolinks="false">
<i class="sn-icon sn-icon-export"></i>
</a>
<!-- more options -->
<ContextMenu
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@menu-toggle="toggleContextMenu"
@attachment:update="$emit('attachment:update', $event)"
/>
</div>
<div class="list-attachment-container asset"
:data-asset-id="attachment.id"
>
<i class="text-sn-grey asset-icon sn-icon" :class="attachment.attributes.icon"></i>
<a :href="attachment.attributes.urls.blob"
class="file-preview-link file-name"
:id="`modal_link${attachment.id}`"
data-no-turbolink="true"
:data-id="attachment.id"
:data-gallery-view-id="parentId"
:data-preview-url="attachment.attributes.urls.preview"
>
<span class="attachment-name" data-toggle="tooltip"
data-placement="bottom">
{{ attachment.attributes.file_name }}
</span>
</a>
<div v-if="attachment.attributes.medium_preview !== null" class="attachment-image-tooltip bg-white sn-shadow-menu-sm">
<img :src="this.imageLoadError ? attachment.attributes.urls.blob : attachment.attributes.medium_preview" @error="ActiveStoragePreviews.reCheckPreview"
@load="ActiveStoragePreviews.showPreview"/>
</div>
<div class="file-metadata">
<span>
{{ i18n.t('assets.placeholder.modified_label') }}
{{ attachment.attributes.updated_at_formatted }}
</span>
<span>
{{ i18n.t('assets.placeholder.size_label', {size: attachment.attributes.file_size_formatted}) }}
</span>
</div>
<ContextMenu
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:update="$emit('attachment:update', $event)"
/>
</div>
<Teleport to="body">
<moveAssetModal

View file

@ -126,6 +126,18 @@
/>
</div>
</div>
<ContextMenu
v-show="showOptions"
:attachment="attachment"
@attachment:viewMode="updateViewMode"
@attachment:delete="deleteAttachment"
@attachment:moved="attachmentMoved"
@attachment:uploaded="reloadAttachments"
@attachment:changed="$emit('attachment:changed', $event)"
@attachment:update="$emit('attachment:update', $event)"
@menu-visibility-changed="handleMenuVisibilityChange"
:withBorder="true"
/>
<Teleport to="body">
<deleteAttachmentModal
v-if="deleteModal"

View file

@ -1,6 +1,6 @@
<template>
<div ref="modal" @keydown.esc="close" class="modal" id="renameAttachmentModal" tabindex="-1" role="dialog">
<div class="modal-dialog modal-md" role="document">
<div class="modal-dialog modal-sm" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><i class="sn-icon sn-icon-close"></i></button>
@ -9,9 +9,14 @@
</h4>
</div>
<div class="modal-body">
<p>{{ i18n.t('assets.from_clipboard.file_name')}}</p>
<div class="sci-input-container" :class="{ 'error': error }" :data-error-text="error">
<input ref="input" v-model="name" type="text" class="sci-input-field" @keyup.enter="renameAttachment(name)" required="true" />
<label class="sci-label">
{{ i18n.t('assets.from_clipboard.file_name')}}
</label>
<div class="flex gap-1 items-center">
<div class="sci-input-container" :class="{ 'error': error }" :data-error-text="error">
<input ref="input" v-model="name" type="text" class="sci-input-field" @keyup.enter="renameAttachment(name)" required="true" />
</div>
<div v-if="this.attachment.attributes.file_extension" class="shrink-0">.{{ this.attachment.attributes.file_extension }}</div>
</div>
</div>
<div class="modal-footer">
@ -31,14 +36,10 @@ export default {
name: 'RenameAttachmentModal',
mixins: [modalMixin],
props: {
url_path: {
type: String,
attachment: {
type: Object,
required: true
},
fileName: {
type: String,
required: true
}
},
data() {
return {
@ -47,7 +48,7 @@ export default {
};
},
created() {
this.name = this.fileName;
this.name = this.attachment.attributes.file_name_without_extension;
},
watch: {
name() {
@ -71,10 +72,17 @@ export default {
return;
}
const payload = { asset: { name: newName } };
let fileName = '';
if (this.attachment.attributes.file_extension) {
fileName = `${newName}.${this.attachment.attributes.file_extension}`;
} else {
fileName = newName;
}
const payload = { asset: { name: fileName } };
try {
const response = await axios.patch(this.url_path, payload);
const response = await axios.patch(this.attachment.attributes.urls.rename, payload);
this.$emit('attachment:update', response.data.data);
this.close();
} catch (error) {

View file

@ -1,6 +1,6 @@
<template>
<div class="inline" v-if="listItems.length > 0 || alwaysShow" v-click-outside="closeMenu" >
<button ref="field" :class="btnClasses" :title="title" @click="isOpen = !isOpen">
<div class="relative" v-if="listItems.length > 0 || alwaysShow" v-click-outside="closeMenu" >
<button ref="field" :class="btnClasses" :title="title" @click="isOpen = !isOpen" :data-e2e="e2eSortButton">
<i v-if="btnIcon" :class="btnIcon"></i>
{{ btnText }}
<i v-if="caret && isOpen" class="sn-icon sn-icon-up"></i>
@ -79,7 +79,8 @@ export default {
btnIcon: { type: String, required: false },
caret: { type: Boolean, default: false },
alwaysShow: { type: Boolean, default: false },
title: { type: String, default: '' }
title: { type: String, default: '' },
e2eSortButton: { type: String, default: '' }
},
data() {
return {

View file

@ -2,7 +2,8 @@
<div v-click-outside="close"
@focus="open"
@keydown="keySelectOptions($event)"
tabindex="0" class="w-full focus:outline-none "
tabindex="0" class="w-full focus:outline-none"
:data-e2e="e2eValue"
>
<div
ref="field"
@ -26,8 +27,9 @@
ref="search"
v-else
v-model="query"
:placeholder="placeholderRender"
@keyup="fetchOptions"
:placeholder="label || placeholder || this.i18n.t('general.select_dropdown.placeholder')"
@change.stop
class="w-full border-0 outline-none pl-0 placeholder:text-sn-grey" />
</template>
<div v-else class="flex items-center gap-1 flex-wrap">
@ -43,6 +45,7 @@
:placeholder="tags.length > 0 ? '' : (placeholder || this.i18n.t('general.select_dropdown.placeholder'))"
:style="{ width: searchInputSize }"
:class="{ 'pl-2': tags.length > 0 }"
@change.stop
class="border-0 outline-none pl-0 py-1 placeholder:text-sn-grey" />
<div v-else-if="tags.length == 0" class="text-sn-grey truncate">
{{ placeholder || this.i18n.t('general.select_dropdown.placeholder') }}
@ -125,7 +128,8 @@ export default {
searchable: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
tagsView: { type: Boolean, default: false },
urlParams: { type: Object, default: () => ({}) }
urlParams: { type: Object, default: () => ({}) },
e2eValue: { type: String, default: '' }
},
directives: {
'click-outside': vOnClickOutside
@ -144,6 +148,13 @@ export default {
},
mixins: [FixedFlyoutMixin],
computed: {
placeholderRender() {
if (this.searchable && this.labelRenderer && this.label) {
return '';
}
return this.label || this.placeholder || this.i18n.t('general.select_dropdown.placeholder');
},
sizeClass() {
switch (this.size) {
case 'xs':
@ -301,8 +312,6 @@ export default {
});
},
setValue(value) {
this.query = '';
if (this.multiple) {
if (this.newValue.includes(value)) {
this.newValue = this.newValue.filter((v) => v !== value);

View file

@ -73,9 +73,10 @@ class Asset < ApplicationRecord
.where(results: { id: Result.search(user, include_archived, nil, teams) })
.pluck(:id)
assets_in_inventories = Asset.joins(
repository_cell: { repository_column: :repository }
).where(repositories: { team: teams }).pluck(:id)
assets_in_inventories = Asset.joins(repository_cell: { repository_column: :repository })
.where(repositories: { team: teams })
.where.not(repositories: { type: 'RepositorySnapshot' })
.pluck(:id)
assets = distinct.where('assets.id IN (?) OR assets.id IN (?) OR assets.id IN (?)',
assets_in_steps, assets_in_results, assets_in_inventories)

View file

@ -10,7 +10,7 @@ class AssetSyncToken < ApplicationRecord
validates :token, uniqueness: true, presence: true
def version_token
asset.file.checksum
OpenSSL::Digest::SHA256.base64digest(asset.file.checksum + asset.file_name)
end
def token_valid?

View file

@ -36,6 +36,20 @@ module SearchableByNameModel
sql_q.limit(options[:limit] || Constants::SEARCH_LIMIT)
end
def self.search_by_search_fields_with_boolean(user, teams = [], query = nil, search_fields = [], options = {})
return if user.blank? || teams.blank?
sanitized_query = ActiveRecord::Base.sanitize_sql_like(query.to_s)
sql_q = if options[:fetch_latest_versions]
viewable_by_user(user, teams, options)
.where_attributes_like_boolean(search_fields, sanitized_query, options)
else
viewable_by_user(user, teams).where_attributes_like_boolean(search_fields, sanitized_query, options)
end
sql_q.limit(options[:limit] || Constants::SEARCH_LIMIT)
end
end
# rubocop:enable Metrics/BlockLength
end

View file

@ -2,6 +2,7 @@
module SearchableModel
extend ActiveSupport::Concern
DATA_VECTOR_ATTRIBUTES = ['asset_text_data.data_vector', 'tables.data_vector'].freeze
included do
# Helper function for relations that
@ -102,48 +103,32 @@ module SearchableModel
scope :where_attributes_like_boolean, lambda { |attributes, query, options = {}|
return unless query
attrs = normalized_attributes(attributes)
where_array = []
value_array = {}
current_phrase = ''
exact_match = false
negate = false
index = 0
normalized_attrs = normalized_attributes(attributes)
query_clauses = []
value_hash = {}
query.split.each do |phrase|
phrase = phrase.strip
if phrase.start_with?('"') && phrase.ends_with?('"')
create_query(attrs, index, negate, where_array, value_array, phrase[1..-2], true)
negate = false
elsif phrase.start_with?('"')
exact_match = true
current_phrase = phrase[1..]
elsif exact_match && phrase.ends_with?('"')
exact_match = false
create_query(attrs, index, negate, where_array, value_array, "#{current_phrase} #{phrase[0..-2]}", true)
current_phrase = ''
negate = false
elsif exact_match
current_phrase = "#{current_phrase} #{phrase}"
elsif phrase.casecmp('and').zero?
next
elsif phrase.casecmp('not').zero?
negate = true
elsif phrase.casecmp('or').zero?
where_array[-1] = "#{where_array.last[0..-5]} OR "
extract_phrases(query).each_with_index do |phrase, index|
if options[:with_subquery]
subquery_result = if phrase[:negate]
options[:raw_input].where.not(id: search_subquery(phrase[:query], options[:raw_input]))
else
options[:raw_input].where(id: search_subquery(phrase[:query], options[:raw_input]))
end
query_clauses = if index.zero?
where(id: subquery_result)
elsif phrase[:current_operator] == 'or'
query_clauses.or(subquery_result)
else
query_clauses.and(subquery_result)
end
else
create_query(attrs, index, negate, where_array, value_array, "%#{phrase}%")
negate = false
phrase[:current_operator] = '' if index.zero?
create_query_clause(normalized_attrs, index, phrase[:negate], query_clauses,
value_hash, phrase[:query], phrase[:current_operator])
end
index += 1
end
if current_phrase.present?
current_phrase = current_phrase[0..-2] if current_phrase.ends_with?('"')
create_query(attrs, index, negate, where_array, value_array, current_phrase, true)
end
where(where_array.join[0..-5], value_array)
options[:with_subquery] ? query_clauses : where(query_clauses.join, value_hash)
}
def self.normalized_attributes(attributes)
@ -163,35 +148,76 @@ module SearchableModel
attrs
end
def self.create_query(attrs, index, negate, where_array, value_array, phrase, exact_match=false)
like = exact_match ? '~' : 'ILIKE'
phrase = "\\m#{phrase}\\M" if exact_match
def self.extract_phrases(query)
extracted_phrases = []
negate = false
current_operator = ''
where_clause = (attrs.map.with_index do |a, i|
i = (index * attrs.count) + i
if %w(repository_rows.id repository_number_values.data).include?(a)
"#{a} IS NOT NULL AND (((#{a})::text) #{like} :t#{i}) OR "
elsif defined?(model::PREFIXED_ID_SQL) && a == model::PREFIXED_ID_SQL
"#{a} IS NOT NULL AND (#{a} #{like} :t#{i}) OR "
elsif a == 'asset_text_data.data_vector'
"asset_text_data.data_vector @@ plainto_tsquery(:t#{i})) OR"
query.scan(/"[^"]+"|\S+/) do |phrase|
phrase = phrase.to_s.strip
case phrase.downcase
when *%w(and or)
current_operator = phrase.downcase
when 'not'
negate = true
else
"#{a} IS NOT NULL AND ((trim_html_tags(#{a})) #{like} :t#{i}) OR "
extracted_phrases << { query: phrase,
negate: negate,
current_operator: current_operator.presence || 'and' }
current_operator = ''
negate = false
end
end
extracted_phrases
end
def self.create_query_clause(attrs, index, negate, query_clauses, value_hash, phrase, current_operator)
phrase = sanitize_sql_like(phrase)
exact_match = phrase =~ /^".*"$/
like = exact_match ? '~' : 'ILIKE'
where_clause = (attrs.map.with_index do |attribute, i|
i = (index * attrs.count) + i
if %w(repository_rows.id repository_number_values.data).include?(attribute)
"#{attribute} IS NOT NULL AND (((#{attribute})::text) #{like} :t#{i}) OR "
elsif defined?(model::PREFIXED_ID_SQL) && attribute == model::PREFIXED_ID_SQL
"#{attribute} IS NOT NULL AND (#{attribute} #{like} :t#{i}) OR "
elsif DATA_VECTOR_ATTRIBUTES.include?(attribute)
"#{attribute} @@ to_tsquery(:t#{i}) OR "
else
"#{attribute} IS NOT NULL AND ((trim_html_tags(#{attribute})) #{like} :t#{i}) OR "
end
end).join[0..-5]
where_array << if negate
"NOT (#{where_clause}) AND "
else
"(#{where_clause}) AND "
end
query_clauses << if negate
" #{current_operator} NOT (#{where_clause})"
else
"#{current_operator} (#{where_clause})"
end
value_array.merge!(
(attrs.map.with_index do |_, i|
value_hash.merge!(
(attrs.map.with_index do |attribute, i|
i = (index * attrs.count) + i
["t#{i}".to_sym, phrase]
new_phrase = exact_match ? phrase[1..-2] : phrase
if DATA_VECTOR_ATTRIBUTES.include?(attribute)
new_phrase = Regexp.escape(new_phrase.gsub(/[!()&|:<]/, ' ').strip).split(/\s+/)
new_phrase.map! { |t| "#{t}:*" } unless exact_match
new_phrase = new_phrase.join('&').tr('\'', '"')
else
new_phrase = Regexp.escape(new_phrase)
new_phrase = exact_match ? "(^|\\s)#{new_phrase}(\\s|$)" : "%#{new_phrase}%"
end
["t#{i}".to_sym, new_phrase]
end).to_h
)
end
def self.search_subquery(query, raw_input)
raise NotImplementedError
end
end
end

View file

@ -14,7 +14,7 @@ module TinyMceImages
before_save :clean_tiny_mce_image_urls
after_create :ensure_extracted_image_object_references
def prepare_for_report(field)
def prepare_for_report(field, export_all: false)
description = self[field]
# Check tinymce for old format
@ -29,15 +29,17 @@ module TinyMceImages
)[0]
next unless tm_asset_to_update
tm_asset = tm_asset.image.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT).processed
unless export_all
tm_asset = tm_asset.image.representation(resize_to_limit: Constants::LARGE_PIC_FORMAT).processed
width_attr = tm_asset_to_update.attributes['width']
height_attr = tm_asset_to_update.attributes['height']
width_attr = tm_asset_to_update.attributes['width']
height_attr = tm_asset_to_update.attributes['height']
if width_attr && height_attr && (width_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[0] ||
height_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[1])
width_attr.value = tm_asset.image.blob.metadata['width'].to_s
height_attr.value = tm_asset.image.blob.metadata['height'].to_s
if width_attr && height_attr && (width_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[0] ||
height_attr.value.to_i >= Constants::LARGE_PIC_FORMAT[1])
width_attr.value = tm_asset.image.blob.metadata['width'].to_s
height_attr.value = tm_asset.image.blob.metadata['height'].to_s
end
end
tm_asset_to_update.attributes['src'].value = convert_to_base64(tm_asset.image)

View file

@ -73,7 +73,7 @@ class Experiment < ApplicationRecord
.where(user_assignments: { team: teams })
.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, options)
new_query = new_query.active unless include_archived
new_query = new_query.joins(:project).active.where(projects: { archived: false }) unless include_archived
new_query
end

View file

@ -56,6 +56,7 @@ class MyModule < ApplicationRecord
belongs_to :changing_from_my_module_status, optional: true, class_name: 'MyModuleStatus'
delegate :my_module_status_flow, to: :my_module_status, allow_nil: true
has_many :results, inverse_of: :my_module, dependent: :destroy
has_many :results_include_discarded, -> { with_discarded }, class_name: 'Result', inverse_of: :my_module
has_many :my_module_tags, inverse_of: :my_module, dependent: :destroy
has_many :tags, through: :my_module_tags, dependent: :destroy
has_many :task_comments, foreign_key: :associated_id, dependent: :destroy
@ -119,7 +120,11 @@ class MyModule < ApplicationRecord
.where(user_assignments: { team: teams })
.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, options)
new_query = new_query.active unless include_archived
unless include_archived
new_query = new_query.joins(experiment: :project)
.active
.where(experiments: { archived: false }, projects: { archived: false })
end
new_query
end

View file

@ -3,6 +3,7 @@
class ProjectFolder < ApplicationRecord
ID_PREFIX = 'PF'
include PrefixedIdModel
SEARCHABLE_ATTRIBUTES = ['project_folders.name', PREFIXED_ID_SQL].freeze
include ArchivableModel
include SearchableModel
@ -43,7 +44,7 @@ class ProjectFolder < ApplicationRecord
teams = options[:teams] || current_team || user.teams.select(:id)
new_query = distinct.viewable_by_user(user, teams)
.where_attributes_like_boolean('project_folders.name', query, options)
.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, options)
new_query = new_query.active unless include_archived
new_query

View file

@ -6,7 +6,7 @@ class Protocol < ApplicationRecord
include ArchivableModel
include PrefixedIdModel
SEARCHABLE_ATTRIBUTES = ['protocols.name', 'protocols.description', PREFIXED_ID_SQL, 'steps.name',
'step_texts.name', 'step_texts.text', 'tables.name',
'step_texts.name', 'step_texts.text', 'tables.name', 'tables.data_vector',
'checklists.name', 'checklist_items.text', 'comments.message'].freeze
REPOSITORY_TYPES = %i(in_repository_published_original in_repository_draft in_repository_published_version).freeze
@ -173,28 +173,41 @@ class Protocol < ApplicationRecord
protocol_templates = if options[:options]&.dig(:in_repository).present? || options[:options].blank?
templates = latest_available_versions(teams)
.with_granted_permissions(user, ProtocolPermissions::READ)
.select(:id)
templates = templates.active unless include_archived
templates
templates.select(:id)
end || []
protocol_my_modules = if options[:options]&.dig(:in_repository).blank?
protocols = distinct.joins(:my_module)
.joins("INNER JOIN user_assignments my_module_user_assignments " \
"ON my_module_user_assignments.assignable_type = 'MyModule' " \
"AND my_module_user_assignments.assignable_id = my_modules.id")
.where(my_module_user_assignments: { user_id: user })
.where(team: teams)
protocols = protocols.active unless include_archived
protocols.pluck(:id)
protocols = viewable_by_user_my_module_protocols(user, teams)
unless include_archived
protocols = protocols.joins(my_module: { experiment: :project })
.active
.where(my_modules: { archived: false },
experiments: { archived: false },
projects: { archived: false })
end
protocols.select(:id)
end || []
distinct.left_joins(steps: [:step_texts, { step_tables: :table },
{ checklists: :checklist_items }, :step_comments])
.where('(protocols.protocol_type IN (?) AND protocols.id IN (?)) OR (protocols.id IN (?))',
[Protocol.protocol_types[:unlinked], Protocol.protocol_types[:linked]],
protocol_my_modules, protocol_templates)
.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, options)
protocols = Protocol.where('(protocols.protocol_type IN (?) AND protocols.id IN (?)) OR (protocols.id IN (?))',
[Protocol.protocol_types[:unlinked], Protocol.protocol_types[:linked]],
protocol_my_modules, protocol_templates)
protocols.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, { with_subquery: true, raw_input: protocols })
end
def self.search_subquery(query, raw_input)
raw_input.where_attributes_like_boolean(['protocols.name', 'protocols.description', PREFIXED_ID_SQL], query)
.or(raw_input.where(id: Step.left_joins(:step_texts, { step_tables: :table },
{ checklists: :checklist_items }, :step_comments)
.where(protocol: raw_input)
.where_attributes_like_boolean(['steps.name', 'step_texts.name',
'step_texts.text', 'tables.name',
'tables.data_vector', 'comments.message',
'checklists.name', 'checklist_items.text'],
query)
.select(:protocol_id)))
end
def self.latest_available_versions(teams)
@ -216,19 +229,37 @@ class Protocol < ApplicationRecord
where('protocols.id IN ((?) UNION (?) UNION (?))', original_without_versions, published_versions, new_drafts)
end
def self.viewable_by_user(user, teams)
# Team owners see all protocol templates in the team
owner_role = UserRole.find_predefined_owner_role
protocols = Protocol.where(team: teams)
.where(protocol_type: REPOSITORY_TYPES)
viewable_as_team_owner = protocols.joins("INNER JOIN user_assignments team_user_assignments " \
"ON team_user_assignments.assignable_type = 'Team' " \
"AND team_user_assignments.assignable_id = protocols.team_id")
.where(team_user_assignments: { user_id: user, user_role_id: owner_role })
.select(:id)
viewable_as_assigned = protocols.with_granted_permissions(user, ProtocolPermissions::READ).select(:id)
def self.viewable_by_user(user, teams, options = {})
if options[:fetch_latest_versions]
protocol_templates = latest_available_versions(teams)
.with_granted_permissions(user, ProtocolPermissions::READ)
.select(:id)
protocol_my_modules = viewable_by_user_my_module_protocols(user, teams).select(:id)
where('protocols.id IN ((?) UNION (?))', viewable_as_team_owner, viewable_as_assigned)
where('protocols.id IN ((?) UNION (?))', protocol_templates, protocol_my_modules)
else
# Team owners see all protocol templates in the team
owner_role = UserRole.find_predefined_owner_role
protocols = Protocol.where(team: teams)
.where(protocol_type: REPOSITORY_TYPES)
viewable_as_team_owner = protocols.joins("INNER JOIN user_assignments team_user_assignments " \
"ON team_user_assignments.assignable_type = 'Team' " \
"AND team_user_assignments.assignable_id = protocols.team_id")
.where(team_user_assignments: { user_id: user, user_role_id: owner_role })
.select(:id)
viewable_as_assigned = protocols.with_granted_permissions(user, ProtocolPermissions::READ).select(:id)
where('protocols.id IN ((?) UNION (?))', viewable_as_team_owner, viewable_as_assigned)
end
end
def self.viewable_by_user_my_module_protocols(user, teams)
distinct.joins(:my_module)
.joins("INNER JOIN user_assignments my_module_user_assignments " \
"ON my_module_user_assignments.assignable_type = 'MyModule' " \
"AND my_module_user_assignments.assignable_id = my_modules.id")
.where(my_module_user_assignments: { user_id: user })
.where(team: teams)
end
def self.filter_by_teams(teams = [])

View file

@ -5,12 +5,15 @@ class Result < ApplicationRecord
include SearchableModel
include SearchableByNameModel
include ViewableModel
include Discard::Model
default_scope -> { kept }
auto_strip_attributes :name, nullify: false
validates :name, length: { maximum: Constants::NAME_MAX_LENGTH }
SEARCHABLE_ATTRIBUTES = ['results.name', 'result_texts.name', 'result_texts.text',
'tables.name', 'comments.message'].freeze
'tables.name', 'tables.data_vector', 'comments.message'].freeze
enum assets_view_mode: { thumbnail: 0, list: 1, inline: 2 }
@ -48,11 +51,20 @@ class Result < ApplicationRecord
"ON my_module_user_assignments.assignable_type = 'MyModule' " \
"AND my_module_user_assignments.assignable_id = my_modules.id")
.where(my_module_user_assignments: { user_id: user, team_id: teams })
.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, options)
new_query = new_query.active unless include_archived
unless include_archived
new_query = new_query.joins(my_module: { experiment: :project })
.active
.where(my_modules: { archived: false },
experiments: { archived: false },
projects: { archived: false })
end
new_query
new_query.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, { with_subquery: true, raw_input: new_query })
end
def self.search_subquery(query, raw_input)
raw_input.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query)
end
def duplicate(my_module, user, result_name: nil)

View file

@ -509,6 +509,22 @@ class User < ApplicationRecord
.find_by(user_identities: { provider: provider_conf['provider_name'], uid: token_payload[:sub] })
end
def self.from_api_key(api_key)
where('api_key_expires_at > ?', Time.current).find_by(api_key: api_key)
end
def regenerate_api_key!
update!(
api_key: SecureRandom.urlsafe_base64(33),
api_key_created_at: Time.current,
api_key_expires_at: Constants::API_KEY_EXPIRES_IN.from_now
)
end
def revoke_api_key!
update!(api_key: nil, api_key_expires_at: nil)
end
def has_linked_account?(provider)
user_identities.exists?(provider: provider)
end
@ -614,6 +630,10 @@ class User < ApplicationRecord
%w(id due_date age results status archived assigned tags comments)
end
def api_key_enabled?
Rails.configuration.x.core_api_key_enabled
end
protected
def confirmation_required?

View file

@ -9,7 +9,7 @@ class AssetSerializer < ActiveModel::Serializer
include ApplicationHelper
attributes :file_name, :file_extension, :view_mode, :icon, :urls, :updated_at_formatted,
:file_size, :medium_preview, :large_preview, :asset_type, :wopi,
:file_size, :medium_preview, :large_preview, :asset_type, :wopi, :file_name_without_extension,
:wopi_context, :pdf_previewable, :file_size_formatted, :asset_order,
:updated_at, :metadata, :image_editable, :image_context, :pdf, :attached, :parent_type,
:edit_version_range
@ -23,6 +23,10 @@ class AssetSerializer < ActiveModel::Serializer
object.render_file_name
end
def file_name_without_extension
File.basename(object.file_name, '.*')
end
def file_extension
File.extname(object.file_name)[1..]
end

View file

@ -26,7 +26,8 @@ module BreadcrumbsHelper
end
when Result
parent = subject.my_module
url = my_module_results_path(subject.my_module)
view_mode = subject.archived? ? 'archived' : 'active'
url = my_module_results_path(subject.my_module, view_mode:)
when ProjectFolder
parent = subject.team
url = project_folder_path(subject)
@ -34,8 +35,15 @@ module BreadcrumbsHelper
parent = subject.team
url = repository_path(subject)
when RepositoryRow
parent = subject.team
url = repository_path(subject.repository)
parent = subject.repository
params = {
id: subject.repository_id,
landing_page: true,
row_id: subject.id
}
params[:archived] = true if subject.archived
url = repository_path(params)
when Report
parent = subject.team
@ -56,7 +64,7 @@ module BreadcrumbsHelper
url = projects_path(team: subject.id)
end
breadcrumbs << { name: subject.name, url: url } if subject.name.present?
breadcrumbs << { name: subject.name, url: } if subject.name.present?
if parent
generate_breadcrumbs(parent, breadcrumbs)

View file

@ -61,7 +61,6 @@ module Lists
assigned_tags: assigned_tags_my_module_my_module_tags_path(object),
users_list: search_my_module_user_my_module_path(object, my_module_id: object.id),
experiments_to_move: experiments_to_move_experiment_path(object.experiment),
move: move_modules_experiment_path(object.experiment),
update: my_module_path(object),
show_access: access_permissions_my_module_path(object),
provisioning_status: provisioning_status_my_module_url(object)

View file

@ -31,11 +31,7 @@ module Lists
end
def code
if project?
object.code
else
"F#{object.id}" # We don't have proper code for folders, but we need to correct selection
end
object.code
end
def created_at

View file

@ -7,13 +7,13 @@ class QuickSearchSerializer < ActiveModel::Serializer
attributes :updated_at, :archived, :breadcrumbs, :code
def archived
@object.archived?
@object.respond_to?(:archived_branch?) ? @object.archived_branch? : @object.archived?
rescue StandardError
false
end
def code
@object.respond_to?(:code) ? @object.code : @object.id
@object.code if @object.respond_to?(:code)
end
def updated_at

View file

@ -55,9 +55,20 @@ class ActivitiesService
child_model = parent_model.reflect_on_association(child).class_name.to_sym
next if subjects[child_model]
subjects[child_model] = parent_model.where(id: subjects[subject_name])
.joins(child)
.pluck("#{child.to_s.pluralize}.id")
if subject_name == 'Result'
parent_model = parent_model.with_discarded
end
if child == :results
subjects[child_model] = parent_model.where(id: subjects[subject_name])
.joins(:results_include_discarded)
.pluck('results.id')
else
subjects[child_model] = parent_model.where(id: subjects[subject_name])
.joins(child)
.pluck("#{child.to_s.pluralize}.id")
end
end
end

View file

@ -87,8 +87,15 @@ module Lists
def filter_project_folder_records(records)
records = records.archived if @params[:view_mode] == 'archived'
records = records.active if @params[:view_mode] == 'active'
records = records.where_attributes_like('project_folders.name', @filters[:query]) if @filters[:query].present?
records = records.where_attributes_like('project_folders.name', @params[:search]) if @params[:search].present?
if @filters[:query].present?
records = records.where_attributes_like(['project_folders.name', ProjectFolder::PREFIXED_ID_SQL],
@filters[:query])
end
if @params[:search].present?
records = records.where_attributes_like(['project_folders.name', ProjectFolder::PREFIXED_ID_SQL],
@params[:search])
end
records
end

View file

@ -10,14 +10,14 @@ module Lists
original_without_versions = @raw_data
.where.missing(:published_versions)
.in_repository_published_original
.select(:id)
.select('protocols.id')
published_versions = @raw_data
.in_repository_published_version
.order(:parent_id, version_number: :desc)
.select('DISTINCT ON (parent_id) id')
.select('DISTINCT ON (protocols.parent_id) protocols.id')
new_drafts = @raw_data
.where(protocol_type: Protocol.protocol_types[:in_repository_draft], parent_id: nil)
.select(:id)
.select('protocols.id')
@records = Protocol.where('protocols.id IN (?) OR protocols.id IN (?) OR protocols.id IN (?)',
original_without_versions, published_versions, new_drafts)

View file

@ -45,6 +45,19 @@ class MarvinJsService
asset
end
def update_file_name(new_name, asset_id, current_user, current_team)
asset = current_team.assets.find(asset_id)
prepared_name = prepare_name(new_name)
ActiveRecord::Base.transaction do
asset.last_modified_by = current_user
asset.rename_file(prepared_name)
asset.save!
end
asset
end
private
def connect_asset(asset, params, current_user)

View file

@ -89,7 +89,8 @@ module Toolbars
name: 'move',
label: I18n.t('experiments.table.toolbar.move'),
icon: 'sn-icon sn-icon-move',
type: :emit
type: :emit,
path: move_modules_experiment_path(@my_modules.first.experiment, my_module_ids: @my_modules.pluck(:id))
}
end

View file

@ -106,11 +106,16 @@ module Toolbars
num_of_requests_left = @current_user.exports_left - 1
team = @items.first.team
message = "<p>#{I18n.t('projects.export_projects.modal_text_p1_html',
num_projects: num_projects,
team: team.name)}</p>
<p>#{I18n.t('projects.export_projects.modal_text_p2_html')}</p>"
unless limit.zero?
message = if limit.zero? || num_of_requests_left.positive?
"<p>#{I18n.t('projects.export_projects.modal_text_p1_html',
num_projects: num_projects,
team: team.name)}</p>
<p>#{I18n.t('projects.export_projects.modal_text_p2_html')}</p>"
else
"<p>#{I18n.t('repositories.index.modal_export_limit_exceeded.error_p1_html', limit: limit)}</p>
<p>#{I18n.t('repositories.index.modal_export_limit_exceeded.error_p2_html', limit: limit)}</p>"
end
unless limit.zero? || !num_of_requests_left.positive?
message += "<p><i>#{I18n.t('projects.export_projects.modal_text_p3_html', limit: limit, num: num_of_requests_left)}</i></p>"
end
@ -120,6 +125,7 @@ module Toolbars
label: I18n.t('projects.export_projects.export_button'),
icon: 'sn-icon sn-icon-export',
message: message,
number_of_request_left: limit.zero? ? 1 : num_of_requests_left,
path: export_projects_team_path(team),
number_of_projects: num_projects,
type: :emit

View file

@ -10,7 +10,8 @@
data-module-y="<%= my_module.y %>"
data-module-conns="<%= construct_module_connections(my_module) %>"
data-module-users-tab-url="<%= designated_users_my_module_user_my_modules_url(my_module_id: my_module.id, format: :json) %>"
data-module-tags-url="<%= my_module_tags_experiment_path(my_module.experiment, format: :json) %>">
data-module-tags-url="<%= my_module_tags_experiment_path(my_module.experiment, format: :json) %>"
data-module-url="<%= my_module_path(my_module, format: :json) %>">
<div data-view-mode="active">
<a class="edit-tags-link pull-right" data-remote="true" href="<%= my_module_tags_edit_url(my_module, format: :json) %>">
@ -114,7 +115,4 @@
<div role="tabpanel" class="tab-pane" id="<%= my_module.id %>_comments" data-contents="comments"></div>
</div>
</div>
<%= render partial: 'canvas/full_zoom/tags_modal', locals: { my_module: my_module }%>
</div>

View file

@ -1,25 +1,14 @@
<div id="tagsModalContainer-<%= my_module.id %>" class="vue-tags-modal">
<div ref="tagsModal" class="tags-modal-component" id="tagsModalComponent-<%= my_module.id %>"></div>
<teleport to="body">
<tags-modal v-if="tagsModalOpen"
:params="<%=
{
id: my_module.id,
permissions: {
manage_tags: can_manage_my_module_tags?(my_module)
},
urls: {
assigned_tags: assigned_tags_my_module_my_module_tags_path(my_module),
assign_tags: my_module_my_module_tags_path(my_module)
}
}.to_json
%>"
:tags-colors="<%= Constants::TAG_COLORS.to_json %>"
project-name="<%= my_module.experiment.project.name %>"
project-tags-url="<%= project_tags_path(my_module.experiment.project) %>"
@close="close"
@tags-loaded="syncTags"
/>
</teleport>
</div>
<%= javascript_include_tag 'vue_legacy_tags_modal' %>
<div id="tagsModalContainer" class="vue-tags-modal">
<div ref="tagsModal" class="tags-modal-component" id="tagsModalComponent"></div>
<teleport to="body">
<tags-modal v-if="tagsModalOpen"
:tags-colors="<%= Constants::TAG_COLORS.to_json %>"
:params="myModuleParams"
project-name="<%= @project.name %>"
project-tags-url="<%= project_tags_path(@project) %>"
@close="close"
@tags-loaded="syncTags"
/>
</teleport>
</div>
<%= javascript_include_tag 'vue_legacy_tags_modal' %>

View file

@ -75,6 +75,7 @@
</div>
<!-- Manage tags modal -->
<%= render partial: 'canvas/full_zoom/tags_modal' %>
<%= render partial: "my_modules/modals/manage_module_tags_modal", locals: { my_module: nil } %>
<%= javascript_include_tag("my_modules/tags") %>

View file

@ -1,6 +1,6 @@
<div class="select-container">
<div class="edit-button-container hidden">
<a class="edit-tags-link" data-remote="true" href="<%= my_module_tags_edit_path(@my_module, format: :json) %>">
<a class="edit-tags-link" data-remote="true" href="<%= my_module_path(my_module, format: :json) %>">
<i class="sn-icon sn-icon-settings"></i>
<span class="hidden-xs"><%= t("my_modules.details.manage_tags") %></span>
</a>

View file

@ -51,7 +51,7 @@
<div class="row">
<div class="col-xs-12">
<% if my_module.description.present? %>
<%= custom_auto_link(my_module.prepare_for_report(:description),
<%= custom_auto_link(my_module.prepare_for_report(:description, export_all: export_all),
team: current_team,
simple_format: false,
base64_encoded_imgs: true) %>

View file

@ -14,7 +14,7 @@
</div>
<div class="row module-protocol-description">
<% if @settings.dig('task', 'protocol', 'description') && protocol.description.present? %>
<%= custom_auto_link(protocol.prepare_for_report(:description),
<%= custom_auto_link(protocol.prepare_for_report(:description, export_all: export_all),
team: current_team,
simple_format: false,
base64_encoded_imgs: true) %>

View file

@ -19,7 +19,7 @@
<div class="row">
<div class="col-xs-12">
<div class="text-container ql-editor">
<%= custom_auto_link(result_text.prepare_for_report(:text),
<%= custom_auto_link(result_text.prepare_for_report(:text, export_all: export_all),
team: current_team,
simple_format: false,
tags: %w(img),

View file

@ -14,7 +14,7 @@
</div>
<div class="report-element-body">
<% if step_text.text.present? %>
<%= custom_auto_link(step_text.prepare_for_report(:text),
<%= custom_auto_link(step_text.prepare_for_report(:text, export_all: export_all),
team: current_team,
simple_format: false,
tags: %w(img),

View file

@ -6,6 +6,7 @@
:search-url="'<%= search_path(format: :json) %>'"
teams-url="<%= visible_teams_teams_path %>"
users-url="<%= visible_users_teams_path %>"
:single-team="<%= current_user.teams.count == 1 %>"
current-team="<%= current_team.id %>"
/>
</div>

View file

@ -35,6 +35,10 @@
</div>
</div>
<%= render partial: 'users/registrations/edit_partials/2fa' %>
<% if current_user.api_key_enabled? %>
<%= render partial: 'users/registrations/edit_partials/api_key' %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,32 @@
<div class="mb-8">
<h3 id="api-key"><%= t("users.registrations.edit.api_key.title") %></h3>
<p>
<%= t("users.registrations.edit.api_key.description") %>
</p>
<% if current_user.api_key %>
<div class="api-key-display">
<div class="form-group sci-input-container right-icon !w-1/2">
<%= password_field_tag :api_key,
current_user.api_key,
name: 'api_key',
class: 'form-control sci-input-field !text-sn-black !font-mono !cursor-text',
disabled: 'disabled'
%>
<i class="sn-icon sn-icon-visibility-show show-password" style="cursor: pointer; z-index: 10"></i>
</div>
<% if current_user.api_key_expires_at < Time.current %>
<p class="">
<%= t("users.registrations.edit.api_key.expired") %>
</p>
<% end %>
</div>
<% end %>
<div class="flex">
<% if current_user.api_key %>
<%= button_to t("users.registrations.edit.api_key.regenerate"), users_api_key_regenerate_path, class: "btn btn-primary mr-2" %>
<%= button_to t("users.registrations.edit.api_key.revoke"), users_api_key_revoke_path, class: "btn btn-danger" %>
<% else %>
<%= button_to t("users.registrations.edit.api_key.generate"), users_api_key_regenerate_path, class: "btn btn-primary" %>
<% end %>
</div>
</div>

View file

@ -12,4 +12,6 @@ Rails.application.configure do
config.x.core_api_v1_enabled = ENV['CORE_API_V1_ENABLED'] || false
config.x.core_api_v2_enabled = ENV['CORE_API_V2_ENABLED'] || false
config.x.core_api_key_enabled = ENV['CORE_API_KEY_ENABLED'] == 'true'
end

View file

@ -247,6 +247,8 @@ class Constants
TWO_FACTOR_RECOVERY_CODE_COUNT = 6
TWO_FACTOR_RECOVERY_CODE_LENGTH = 12
API_KEY_EXPIRES_IN = 1.year
#=============================================================================
# Protocol importers
#=============================================================================
@ -425,7 +427,7 @@ class Constants
# Team name for default admin user
DEFAULT_PRIVATE_TEAM_NAME = 'My projects'.freeze
TEMPLATES_PROJECT_NAME = 'Templates'.freeze
TEMPLATES_PROJECT_NAME = 'SciNote Examples'.freeze
# Interval time for polling status state
FAST_STATUS_POLLING_INTERVAL = 5000

View file

@ -3,6 +3,7 @@
module PermissionExtends
module TeamPermissions
%w(
NONE
READ
MANAGE
USERS_MANAGE
@ -17,6 +18,7 @@ module PermissionExtends
module ProtocolPermissions
%w(
NONE
READ
READ_ARCHIVED
MANAGE
@ -27,6 +29,7 @@ module PermissionExtends
module ReportPermissions
%w(
NONE
READ
MANAGE
USERS_MANAGE
@ -35,6 +38,7 @@ module PermissionExtends
module ProjectPermissions
%w(
NONE
READ
READ_ARCHIVED
MANAGE
@ -52,6 +56,7 @@ module PermissionExtends
module ExperimentPermissions
%w(
NONE
READ
READ_ARCHIVED
MANAGE
@ -65,6 +70,7 @@ module PermissionExtends
module MyModulePermissions
%w(
NONE
READ
READ_ARCHIVED
ACTIVITIES_READ
@ -106,6 +112,7 @@ module PermissionExtends
module RepositoryPermissions
%w(
NONE
READ
READ_ARCHIVED
MANAGE
@ -132,7 +139,7 @@ module PermissionExtends
ExperimentPermissions.constants.map { |const| ExperimentPermissions.const_get(const) } +
MyModulePermissions.constants.map { |const| MyModulePermissions.const_get(const) } +
RepositoryPermissions.constants.map { |const| RepositoryPermissions.const_get(const) }
)
).reject { |p| p.end_with?("_none") }
NORMAL_USER_PERMISSIONS = [
TeamPermissions::PROJECTS_CREATE,

Some files were not shown because too many files have changed in this diff Show more