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

This commit is contained in:
Martin Artnik 2024-05-13 11:52:27 +02:00
commit bc6e43052f
182 changed files with 4476 additions and 1617 deletions

View file

@ -299,6 +299,9 @@ Naming/VariableName:
Naming/VariableNumber:
EnforcedStyle: normalcase
Naming/BlockForwarding:
EnforcedStyle: explicit
Style/WordArray:
EnforcedStyle: percent
MinSize: 0

View file

@ -4,6 +4,7 @@ source 'http://rubygems.org'
ruby '3.2.2'
gem 'activerecord-session_store'
gem 'bootsnap', require: false
gem 'devise', '~> 4.8.1'
gem 'devise_invitable'
@ -24,7 +25,9 @@ gem 'omniauth', '~> 2.1'
gem 'omniauth-azure-activedirectory-v2'
gem 'omniauth-linkedin-oauth2'
gem 'omniauth-okta', git: 'https://github.com/scinote-eln/omniauth-okta', branch: 'org_auth_server_support'
gem 'omniauth_openid_connect'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
gem 'omniauth-saml'
# Gems for API implementation
gem 'active_model_serializers', '~> 0.10.7'
@ -92,7 +95,7 @@ gem 'graphviz'
gem 'cssbundling-rails'
gem 'jsbundling-rails'
gem 'tailwindcss-rails', '~> 2.0'
gem 'tailwindcss-rails', '~> 2.4'
gem 'base62' # Used for smart annotations
gem 'newrelic_rpm'

View file

@ -120,6 +120,13 @@ GEM
activesupport (= 7.0.8.1)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activerecord-session_store (2.1.0)
actionpack (>= 6.1)
activerecord (>= 6.1)
cgi (>= 0.3.6)
multi_json (~> 1.11, >= 1.11.2)
rack (>= 2.0.8, < 4)
railties (>= 6.1)
activestorage (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
@ -141,6 +148,7 @@ GEM
railties (>= 3.1)
aspector (0.14.0)
ast (2.4.2)
attr_required (1.0.1)
auto_strip_attributes (2.6.0)
activerecord (>= 4.0)
awesome_print (1.9.2)
@ -219,6 +227,7 @@ GEM
mail
case_transform (0.2)
activesupport
cgi (0.4.1)
childprocess (4.1.0)
chunky_png (1.4.0)
coderay (1.1.3)
@ -478,6 +487,25 @@ GEM
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-saml (2.1.0)
omniauth (~> 2.0)
ruby-saml (~> 1.12)
omniauth_openid_connect (0.7.1)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.2.0)
activemodel
attr_required (>= 1.0.0)
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
net-smtp
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_email
validate_url
webfinger (~> 2.0)
orm_adapter (0.5.0)
overcommit (0.60.0)
childprocess (>= 0.6.3, < 5)
@ -518,6 +546,13 @@ GEM
rack (>= 1.0, < 3)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-oauth2 (2.2.0)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (3.0.6)
rack
rack-test (2.1.0)
@ -626,6 +661,9 @@ GEM
rubocop (>= 1.33.0, < 2.0)
ruby-progressbar (1.13.0)
ruby-rc4 (0.1.5)
ruby-saml (1.16.0)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.1.4)
ffi (~> 1.12)
rubyzip (2.3.2)
@ -663,13 +701,18 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
stream (0.5.5)
swd (2.0.2)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
sys-uname (1.2.3)
ffi (~> 1.1)
tailwindcss-rails (2.0.29)
tailwindcss-rails (2.4.0)
railties (>= 6.0.0)
tailwindcss-rails (2.0.29-arm64-darwin)
tailwindcss-rails (2.4.0-arm64-darwin)
railties (>= 6.0.0)
tailwindcss-rails (2.0.29-x86_64-linux)
tailwindcss-rails (2.4.0-x86_64-linux)
railties (>= 6.0.0)
thor (1.3.1)
tilt (2.2.0)
@ -688,6 +731,12 @@ GEM
unf_ext (0.0.8.2)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
uri (0.13.0)
version_gem (1.1.3)
view_component (3.9.0)
@ -696,6 +745,10 @@ GEM
method_source (~> 1.0)
warden (1.2.9)
rack (>= 2.0.9)
webfinger (2.1.2)
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.18.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@ -722,6 +775,7 @@ PLATFORMS
DEPENDENCIES
active_model_serializers (~> 0.10.7)
activerecord-import
activerecord-session_store
acts_as_list
ajax-datatables-rails (~> 0.3.1)
aspector
@ -780,6 +834,8 @@ DEPENDENCIES
omniauth-linkedin-oauth2
omniauth-okta!
omniauth-rails_csrf_protection (~> 1.0)
omniauth-saml
omniauth_openid_connect
overcommit
pg (~> 1.5)
pg_search
@ -814,7 +870,7 @@ DEPENDENCIES
simplecov
sneaky-save!
sprockets-rails
tailwindcss-rails (~> 2.0)
tailwindcss-rails (~> 2.4)
timecop
turbolinks (~> 5.2.0)
tzinfo-data

View file

@ -1 +1 @@
1.32.0
1.33.0

View file

@ -7,7 +7,7 @@
let myModuleUserSelector = '#my_module_user_ids';
var myModuleTagsSelector = '#module-tags-selector';
$(document).on('submit', '#new-my-module-modal form', (event) => {
$('#experiment-canvas').on('submit', '#new-my-module-modal form', (event) => {
event.preventDefault();
$.post({
@ -16,13 +16,30 @@
my_module: {
name: $('#new-my-module-modal input[name="my_module[name]"]').val(),
view_mode: $('#new-my-module-modal input[name="my_module[view_mode]"]').val(),
due_date: $('#new-my-module-modal input[name="my_module[due-date]"]').val(),
due_date: $('#new-my-module-modal input[name="my_module[due_date]"]').val(),
tag_ids: dropdownSelector.getValues(myModuleTagsSelector),
user_ids: dropdownSelector.getValues(myModuleUserSelector)
}
}
});
});
$('#experiment-canvas').on('shown.bs.modal', () => {
// disable the submit button by default
$('#new-modal-submit-btn').prop('disabled', true);
// listen for input event on the my_module_name input field
$(`${newMyModuleModal} #my_module_name`).on('input', function () {
if ($(this).val().trim() !== '') {
// enable the submit button if the input field is populated
$('#new-modal-submit-btn').prop('disabled', false);
} else {
// otherwise, disable it
$('#new-modal-submit-btn').prop('disabled', true);
}
});
});
// Modal's submit handler function
$(experimentWrapper)
.on('ajax:success', newMyModuleModal, function() {

View file

@ -112,8 +112,8 @@
},
optionLabel: (data) => {
if (data.value > 0) {
return `<span class="my-module-tags-color" style="background:${data.params.color}"></span>
${data.label}`;
return `<span class="h-6 px-1.5 flex items-center max-w-80 truncate text-sn-white rounded"
style="background:${data.params.color}">${data.label}</span>`;
}
return `<span class="my-module-tags-color new"><i class="sn-icon sn-icon-new-task"></i></span>
${data.label + ' '}

View file

@ -329,7 +329,7 @@ function initAccessModal() {
function initWrapTables() {
const viewMode = new URLSearchParams(window.location.search).get('view_mode');
if (['archived', 'locked', 'active'].includes(viewMode)) {
if (['archived', 'locked'].includes(viewMode)) {
setTimeout(() => {
const notesContainerEl = document.getElementById('notes-container');
window.wrapTables(notesContainerEl);

View file

@ -32,7 +32,7 @@ $.fn.dataTable.render.defaultRepositoryAssetValue = function() {
};
$.fn.dataTable.render.RepositoryTextValue = function(data) {
var text = $(`<span class="text-value">${data.value.view}</span>`);
const text = $(`<span class="text-value [&>p]:mb-0">${data.value.view}</span>`);
text.attr('data-edit-value', data.value.edit);
return text.prop('outerHTML');
};

View file

@ -285,12 +285,15 @@ var RepositoryColumns = (function() {
let editUrl = $(el).attr('data-edit-column-url');
let destroyUrl = $(el).attr('data-destroy-column-url');
let thederName;
if ($(el).find('.modal-tooltiptext').length > 0) {
thederName = $(el).find('.modal-tooltiptext').text();
} else {
thederName = el.innerText;
}
thederName = _.escape(thederName);
const e2eName = thederName.toLowerCase().replace(' ', '_');
if (['row-name', 'archived-by', 'archived-on'].includes(el.id)) {
visClass = '';
@ -303,24 +306,24 @@ var RepositoryColumns = (function() {
destroyButton = `<button class="btn icon-btn btn-light btn-xs delete-repo-column manage-repo-column"
data-action="destroy"
data-modal-url="${destroyUrl}">
<span class="sn-icon sn-icon-delete" title="Delete"></span>
<span class="sn-icon sn-icon-delete" title="Delete" data-e2e="e2e-BT-invItems-manageColumnsModal-${e2eName}-delete"></span>
</button>`;
}
let listItem = `<li class="col-list-el ${visLi} ${customColumn} ${editableRow}" data-position="${colIndex}" data-id="${colId}">
<i class="grippy sn-icon sn-icon-drag"></i>
<i class="grippy sn-icon sn-icon-drag" data-e2e="e2e-BT-invItems-manageColumnsModal-${e2eName}-drag"></i>
<span class="vis-controls">
<span class="vis sn-icon ${visClass}" title="${visText}"></span>
<span class="vis sn-icon ${visClass}" title="${visText}" data-e2e="e2e-BT-invItems-manageColumnsModal-${e2eName}-visibility"></span>
</span>
<div class="text truncate" title="${thederName}">${thederName}</div>
<div class="text truncate" title="${thederName}" data-e2e="e2e-TX-invItems-manageColumnsModal-${e2eName}-columnName">${thederName}</div>
<span class="column-type pull-right shrink-0">${
getColumnTypeText(el, colId) || '<i class="sn-icon sn-icon-locked-task"></i>'
getColumnTypeText(el, colId) || `<i class="sn-icon sn-icon-locked-task" data-e2e="e2e-IC-invItems-manageColumnsModal-${e2eName}-locked"></i>`
}</span>
<span class="sci-btn-group manage-controls pull-right" data-view-mode="active">
<button class="btn icon-btn btn-light btn-xs edit-repo-column manage-repo-column"
data-action="edit"
data-modal-url="${editUrl}">
<span class="sn-icon sn-icon-edit" title="Edit"></span>
<span class="sn-icon sn-icon-edit" title="Edit" data-e2e="e2e-BT-invItems-manageColumnsModal-${e2eName}-edit"></span>
</button>
${destroyButton}
</span>

View file

@ -36,7 +36,7 @@
timeoutID = setTimeout(functionCallback, timeoutTime);
}
function toogleDocumentTitle(timeString = null) {
function toggleDocumentTitle(timeString = null) {
var sleepEmoticon = String.fromCodePoint(0x1F62A);
var originalTitle = document.title.split(sleepEmoticon).pop().trim();
@ -70,21 +70,21 @@
function reviveSession() {
$.post($('meta[name=\'revive-url\']').attr('content'));
toogleDocumentTitle();
toggleDocumentTitle();
window.localStorage.removeItem('sessionEnd');
setSessionTimeout(initializeSessionCountdown, oneSecondTimeout);
}
function initializeSessionReviveCallbacks() {
$('#session-expire').modal().off('hide.bs.modal').on('hide.bs.modal', function() {
if (sessionExpireIn() > 0) {
if (sessionExpireIn() > 0 && sessionExpireIn() < expireLimit) {
reviveSession();
}
});
// for manual page reload
$(window).off('beforeunload').on('beforeunload', function() {
if (sessionExpireIn() > 0) {
if (sessionExpireIn() > 0 && sessionExpireIn() < expireLimit) {
reviveSession();
}
});
@ -98,7 +98,7 @@
initializeSessionCountdown();
} else if (expireIn > 0 && expireIn <= expireLimit) {
timeString = newTimerStr(expireIn / 1000);
toogleDocumentTitle(timeString);
toggleDocumentTitle(timeString);
$('.expiring').text(I18n.t('devise.sessions.expire_modal.session_end_in.header', { time: timeString }));
if (!$('#session-expire').hasClass('in')) {
@ -107,7 +107,7 @@
setSessionTimeout(sessionCountdown, oneSecondTimeout);
} else if (expireIn <= 0) {
toogleDocumentTitle();
toggleDocumentTitle();
$('#session-expire').modal('hide');
$('#session-finished').modal();
}
@ -130,7 +130,7 @@
}
setSessionTimeout(initializeSessionCountdown, oneSecondTimeout);
} else if (expireOn && !event.originalEvent.newValue) {
toogleDocumentTitle();
toggleDocumentTitle();
}
expireOn = event.originalEvent.newValue;

View file

@ -108,8 +108,10 @@ let inlineEditing = (function() {
if (response.status === 403) {
HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger');
} else if (response.status === 422) {
HelperModule.flashAlertMsg(response.responseJSON.errors
? Object.values(response.responseJSON.errors).join(', ') : I18n.t('errors.general'), 'danger');
const errors = response.responseJSON.errors || response.responseJSON;
if (!errors) {
HelperModule.flashAlertMsg(I18n.t('errors.general'), 'danger');
}
}
if (!error) error = response.responseJSON.errors[fieldToUpdate];
container.addClass('error');
@ -155,6 +157,7 @@ let inlineEditing = (function() {
if (inputField(container).attr('disabled')) {
saveAllEditFields();
let input = inputField(container);
input.val(container.attr('data-original-name'));
input.attr('disabled', false)
.removeClass('hidden')
.focus();

View file

@ -18,4 +18,6 @@ const GLOBAL_CONSTANTS = {
SLOW_STATUS_POLLING_INTERVAL: <%= Constants::SLOW_STATUS_POLLING_INTERVAL %>,
ASSET_POLLING_INTERVAL: <%= Constants::ASSET_POLLING_INTERVAL %>,
ASSET_SYNC_URL: '<%= Constants::ASSET_SYNC_URL %>',
GLOBAL_SEARCH_PREVIEW_LIMIT: <%= Constants::GLOBAL_SEARCH_PREVIEW_LIMIT %>,
SEARCH_LIMIT: <%= Constants::SEARCH_LIMIT %>
};

View file

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

View file

@ -110,10 +110,9 @@
function(e, data) {
// Populate the modal heading & body
var modal = $('#destroy-user-team-modal');
var modalHeading = modal.find('.modal-header').find('.modal-title');
var modalBody = modal.find('.modal-body');
modalHeading.text($('<div>').html(data.heading).text());
modalBody.html(data.html);
const modalContent = modal.find('.modal-content');
modalContent.html(data.html);
// Show the modal
modal.modal('show');
@ -122,13 +121,14 @@
'ajax:error',
"[data-action='destroy-user-team']",
function() {
// TODO
HelperModule.flashAlertMsg(I18n.t('users.settings.user_teams.general_error'), 'danger');
}
);
// Also, bind the click action on the modal
$('#destroy-user-team-modal')
.on('click', "[data-action='submit']", function() {
animateSpinner();
var btn = $(this);
var form = btn
.closest('.modal')
@ -154,14 +154,16 @@
// Hide the modal
modal.modal('hide');
animateSpinner(null, false);
// Reload the whole table
usersDatatable.ajax.reload();
location.reload();
}
).on(
'ajax:error',
"[data-id='destroy-user-team-form']",
function() {
// TODO
animateSpinner(null, false);
HelperModule.flashAlertMsg(I18n.t('users.settings.user_teams.general_error'), 'danger');
}
);
}

View file

@ -60,3 +60,15 @@ html {
.ag-theme-alpine {
--ag-font-family: "SN Inter", "Open Sans", Arial, Helvetica, sans-serif !important;
}
.animate-skeleton {
background-image: linear-gradient(90deg, #ddd 0px, #e8e8e8 40px, #ddd 80px);
background-size: 500px;
animation: shine-lines 1.6s infinite linear
}
@keyframes shine-lines {
0% { background-position: -150px }
40%, 100% { background-position: 320px }
}

View file

@ -12,6 +12,12 @@
.panel-heading {
padding: 7px 30px 7px 15px;
.panel-title {
align-items: center;
display: flex;
height: 100%;
}
}
.panel-body {
@ -97,4 +103,18 @@
grid-template-columns: 1fr;
}
}
.bootstrap-select .dropdown-toggle:focus {
outline: none !important;
}
.filter-option-inner {
height: 100%;
.filter-option-inner-inner {
align-items: center;
display: flex;
height: 100%;
}
}
}

View file

@ -28,3 +28,13 @@ thead {
display: table-row-group;
}
.report-module-repository-element {
.report-element-header {
.repository-name {
max-width: 100vw;
padding-bottom: 4px;
white-space: normal !important;
}
}
}

View file

@ -116,6 +116,10 @@
.dp__input {
line-height: unset;
&::placeholder {
color: var(--sn-grey);
}
}
}
@ -147,6 +151,13 @@
height: 36px;
}
}
&.borderless-input {
.dp__input {
background-color: transparent;
border-color: transparent;
}
}
}
.dp__theme_light {
@ -182,7 +193,7 @@
&:hover {
border-color: var(--sn-science-blue);
}
border-color: var(--sn-science-blue);
border-color: var(--sn-science-blue) !important;
}
:root {

View file

@ -35,7 +35,7 @@ input[type="checkbox"].sci-checkbox {
&::before {
@include font-awesome;
animation-timing-function: $timing-function-sharp;
background: $color-white;
background: transparent;
border: 1px solid var(--sn-black);
border-radius: 1px;
color: $color-white;

View file

@ -4,7 +4,7 @@
}
.btn {
@apply relative inline-flex items-center text-sm shrink-0 gap-2 justify-center px-4 rounded border border-solid appearance-none whitespace-nowrap cursor-pointer h-[40px] focus:outline-none;
@apply relative inline-flex items-center text-sm shrink-0 gap-2 justify-center px-4 rounded border border-solid appearance-none whitespace-nowrap cursor-pointer h-[40px];
border-color: transparent;
}
@ -33,7 +33,7 @@
}
.btn.btn-xs.icon-btn {
@apply px-0.5;
@apply px-0.5 w-[30px];
}
.btn:hover {
@ -41,7 +41,7 @@
}
.btn:focus {
@apply no-underline outline-none text-sn-white;
@apply no-underline outline outline-4 outline-sn-science-blue-hover text-sn-white;
}
.btn:active {
@ -58,6 +58,11 @@
@apply bg-sn-blue text-sn-white;
}
.btn.btn-primary:active,
.btn.btn-primary.active {
@apply bg-sn-blue-click;
}
.btn.btn-primary:hover,
.btn.btn-success:hover,
.btn.btn-primary:focus,
@ -81,6 +86,11 @@
@apply bg-sn-science-blue text-sn-white border-sn-white;
}
.btn.btn-secondary:active,
.btn.btn-secondary.active {
@apply bg-sn-super-light-blue;
}
.btn.btn-secondary:hover,
.btn.btn-default:hover,
.btn.btn-secondary:focus {
@ -123,6 +133,11 @@
@apply bg-sn-super-light-grey;
}
.btn.btn-light:active,
.btn.btn-light.active {
@apply bg-sn-grey-100;
}
.btn.btn-light:disabled,
.btn.btn-light.disabled {
@apply text-sn-sleepy-grey;
@ -137,6 +152,11 @@
@apply bg-sn-delete-red-hover;
}
.btn.btn-danger:active,
.btn.btn-danger.active {
@apply bg-sn-delete-red-click;
}
.btn.btn-danger:disabled,
.btn.btn-danger.disabled {
@apply bg-sn-delete-red-disabled;

View file

@ -21,7 +21,7 @@
}
.sci-input-container-v2 input::placeholder {
@apply text-sn-sleepy-grey;
@apply text-sn-grey;
}
.sci-input-container-v2 .error {
@ -40,7 +40,8 @@
width: 100%;
}
.sci-input-container-v2 input:focus {
.sci-input-container-v2 input:focus,
.sci-input-container-v2 input.active {
@apply border-sn-science-blue shadow-none;
}
@ -83,7 +84,7 @@
}
.sci-input-container-v2 textarea::placeholder {
@apply text-sn-sleepy-grey;
@apply text-sn-grey;
}
.sci-input-container-v2 textarea:focus {

View file

@ -5,7 +5,7 @@ class AssetSyncController < ApplicationController
skip_before_action :authenticate_user!, only: %i(update download)
skip_before_action :verify_authenticity_token, only: %i(update download)
before_action :authenticate_asset_sync_token!, only: %i(update download)
prepend_before_action :authenticate_asset_sync_token!, only: %i(update download)
before_action :check_asset_sync
def show
@ -117,7 +117,8 @@ class AssetSyncController < ApplicationController
render_error(:unauthorized) and return unless @asset_sync_token&.token_valid?
@asset = @asset_sync_token.asset
@current_user = @asset_sync_token.user
sign_in(@asset_sync_token.user)
render_error(:forbidden, @asset.file.filename) and return unless can_manage_asset?(@asset)
end

View file

@ -22,7 +22,9 @@ module Dashboard
def project_filter
projects = Project.readable_by_user(current_user)
.search(current_user, false, params[:query], 1, current_team)
.search(current_user, false, params[:query], current_team)
.page(params[:page] || 1)
.per(Constants::SEARCH_LIMIT)
.select(:id, :name)
projects = projects.map { |i| [i.id, escape_input(i.name)] }
if (projects.map { |i| i[1] }.exclude? params[:query]) && params[:query].present?
@ -37,7 +39,9 @@ module Dashboard
elsif @project
experiments = @project.experiments
.managable_by_user(current_user)
.search(current_user, false, params[:query], 1, current_team)
.search(current_user, false, params[:query], current_team)
.page(params[:page] || 1)
.per(Constants::SEARCH_LIMIT)
.select(:id, :name)
experiments = experiments.map { |i| [i.id, escape_input(i.name)] }
if (experiments.map { |i| i[1] }.exclude? params[:query]) &&

View file

@ -1,6 +1,10 @@
# frozen_string_literal: true
class DashboardsController < ApplicationController
include TeamsHelper
before_action :switch_team_with_param, only: :show
def show
@my_module_status_flows = MyModuleStatusFlow.all.preload(my_module_statuses: :my_module_status_consequences)
end

View file

@ -116,7 +116,7 @@ class ExperimentsController < ApplicationController
render json: { message: t('experiments.update.success_flash', experiment: @experiment.name) }, status: :ok
else
render json: { message: @experiment.errors.full_messages }, status: :unprocessable_entity
render json: { errors: @experiment.errors }, status: :unprocessable_entity
end
end
@ -452,6 +452,9 @@ class ExperimentsController < ApplicationController
@project = Project.find_by(id: params[:project_id])
render_404 unless @project
current_team_switch(@project.team) if current_team != @project.team
render_403 unless can_read_project?(@project)
end

View file

@ -4,8 +4,8 @@ class LabelTemplatesController < ApplicationController
include InputSanitizeHelper
include TeamsHelper
before_action :check_feature_enabled, except: %i(index zpl_preview)
before_action :load_label_templates, only: %i(index datatable)
before_action :check_feature_enabled, except: %i(index zpl_preview list)
before_action :load_label_templates, only: %i(index datatable list)
before_action :load_label_template, only: %i(show set_default update template_tags)
before_action :check_view_permissions, except: %i(create duplicate set_default delete update)
before_action :check_manage_permissions, only: %i(create duplicate set_default delete update)
@ -29,6 +29,10 @@ class LabelTemplatesController < ApplicationController
end
end
def list
render json: @label_templates, each_serializer: LabelTemplateSerializer, user: current_user
end
def show
respond_to do |format|
format.json { render json: @label_template, serializer: LabelTemplateSerializer, user: current_user }

View file

@ -371,7 +371,11 @@ class ProtocolsController < ApplicationController
def save_as_draft
Protocol.transaction do
draft = @protocol.save_as_draft(current_user)
draft = nil
@protocol.with_lock do
draft = @protocol.save_as_draft(current_user)
end
if draft.invalid?
render json: { error: draft.errors.messages.map { |_, value| value }.join(' ') }, status: :unprocessable_entity

View file

@ -25,6 +25,7 @@ class RepositoriesController < ApplicationController
before_action :check_create_permissions, only: %i(create_modal create)
before_action :check_copy_permissions, only: %i(copy_modal copy)
before_action :set_inline_name_editing, only: %i(show)
before_action :load_repository_row, only: %i(show)
before_action :set_breadcrumbs_items, only: %i(index show)
before_action :validate_file_type, only: %i(export_repository export_repositories)
@ -494,6 +495,14 @@ class RepositoriesController < ApplicationController
@repositories = current_team.repositories.archived.where(id: params[:repository_ids])
end
def load_repository_row
@repository_row = nil
@repository_row_landing_page = true if params[:landing_page].present?
return if params[:row_id].blank?
@repository_row = @repository.repository_rows.find_by(id: params[:row_id])
end
def set_inline_name_editing
return unless can_manage_repository?(@repository)
@ -587,11 +596,11 @@ class RepositoriesController < ApplicationController
def set_breadcrumbs_items
@breadcrumbs_items = []
archived_branch = @repository&.archived? || (!@repository && params[:archived] == 'true')
archived_branch = @repository&.archived? || (!@repository && params[:view_mode] == 'archived')
@breadcrumbs_items.push({
label: t('breadcrumbs.inventories'),
url: archived_branch ? repositories_path(archived: true) : repositories_path,
url: archived_branch ? repositories_path(view_mode: 'archived') : repositories_path,
archived: archived_branch
})

View file

@ -32,11 +32,14 @@ module RepositoryColumns
end
def items
column_list_items = @repository_column.repository_list_items
.where('data ILIKE ?',
"%#{search_params[:query]}%")
.limit(Constants::SEARCH_LIMIT)
.select(:id, :data)
column_list_items = if params[:all_options]
@repository_column.repository_list_items.select(:id, :data)
else
@repository_column.repository_list_items
.where('data ILIKE ?', "%#{search_params[:query]}%")
.order(data: :asc)
.select(:id, :data)
end
render json: column_list_items.map { |i| { value: i.id, label: escape_input(i.data) } }, status: :ok
end

View file

@ -176,7 +176,9 @@ class ResultsController < ApplicationController
def apply_filters!
if params[:query].present?
@results = @results.search(current_user, params[:view_mode] == 'archived', params[:query], params[:page] || 1)
@results = @results.search(current_user, params[:view_mode] == 'archived', params[:query])
.page(params[:page] || 1)
.per(Constants::SEARCH_LIMIT)
end
@results = @results.where('results.created_at >= ?', params[:created_at_from]) if params[:created_at_from]

View file

@ -4,302 +4,246 @@ class SearchController < ApplicationController
before_action :load_vars, only: :index
def index
redirect_to new_search_path unless @search_query
respond_to do |format|
format.html do
redirect_to new_search_path unless @search_query
end
format.json do
redirect_to new_search_path unless @search_query
@search_id = params[:search_id] ? params[:search_id] : generate_search_id
case params[:group]
when 'projects'
search_by_name(Project)
count_search_results
render json: @records.includes(:team, :project_folder),
each_serializer: GlobalSearch::ProjectSerializer,
meta: {
total: @records.total_count,
next_page: (@records.next_page if @records.respond_to?(:next_page)),
}
when 'project_folders'
search_by_name(ProjectFolder)
search_projects if @search_category == :projects
search_project_folders if @search_category == :project_folders
search_experiments if @search_category == :experiments
search_modules if @search_category == :modules
search_results if @search_category == :results
search_tags if @search_category == :tags
search_reports if @search_category == :reports
search_protocols if @search_category == :protocols
search_steps if @search_category == :steps
search_checklists if @search_category == :checklists
if @search_category == :repositories && params[:repository]
search_repository
end
search_assets if @search_category == :assets
search_tables if @search_category == :tables
search_comments if @search_category == :comments
render json: @records.includes(:team, :parent_folder),
each_serializer: GlobalSearch::ProjectFolderSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'reports'
search_by_name(Report)
@search_pages = (@search_count.to_f / Constants::SEARCH_LIMIT.to_f).ceil
@start_page = @search_page - 2
@start_page = 1 if @start_page < 1
@end_page = @start_page + 4
render json: @records.includes(:team, :project, :user),
each_serializer: GlobalSearch::ReportSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'module_protocols'
search_by_name(Protocol, { in_repository: false })
if @end_page > @search_pages
@end_page = @search_pages
@start_page = @end_page - 4
@start_page = 1 if @start_page < 1
render json: @records.joins({ my_module: :experiment }, :team),
each_serializer: GlobalSearch::MyModuleProtocolSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'experiments'
search_by_name(Experiment)
render json: @records.includes(project: :team),
each_serializer: GlobalSearch::ExperimentSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'tasks'
search_by_name(MyModule)
render json: @records.includes(experiment: { project: :team }),
each_serializer: GlobalSearch::MyModuleSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'results'
search_by_name(Result)
render json: @records.includes(my_module: { experiment: { project: :team } }),
each_serializer: GlobalSearch::ResultSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'protocols'
search_by_name(Protocol, { in_repository: true })
render json: @records,
each_serializer: GlobalSearch::ProtocolSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'label_templates'
return render json: [], meta: { disabled: true }, status: :ok unless LabelTemplate.enabled?
search_by_name(LabelTemplate)
render json: @records,
each_serializer: GlobalSearch::LabelTemplateSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'repository_rows'
search_by_name(RepositoryRow)
render json: @records,
each_serializer: GlobalSearch::RepositoryRowSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
when 'assets'
search_by_name(Asset)
includes = [{ step: { protocol: { my_module: :experiment } } }, { result: { my_module: :experiment } }, :team]
render json: @records.includes(includes),
each_serializer: GlobalSearch::AssetSerializer,
meta: {
total: @records.total_count,
next_page: @records.next_page
}
return
end
end
end
end
def new
end
def quick
results = if params[:filter].present?
object_quick_search(params[:filter].singularize)
else
Constants::QUICK_SEARCH_SEARCHABLE_OBJECTS.filter_map do |object|
next if object == 'label_template' && !LabelTemplate.enabled?
object_quick_search(object)
end.flatten.sort_by(&:updated_at).reverse.take(Constants::QUICK_SEARCH_LIMIT)
end
render json: results, each_serializer: QuickSearchSerializer
end
private
def object_quick_search(class_name)
search_model = class_name.to_s.camelize.constantize
search_method = search_model.method(search_model.respond_to?(:code) ? :search_by_name_and_id : :search_by_name)
search_method.call(current_user,
current_team,
params[:query],
limit: Constants::QUICK_SEARCH_LIMIT)
.order(updated_at: :desc)
end
def load_vars
query = (params.fetch(:q) { '' }).strip
@search_category = params[:category] || ''
@search_category = @search_category.to_sym
@search_page = params[:page].to_i || 1
@search_case = params[:match_case] == 'true'
@search_whole_word = params[:whole_word] == 'true'
@search_whole_phrase = params[:whole_phrase] == 'true'
@filters = params[:filters]
@include_archived = @filters.blank? || @filters[:include_archived] == 'true'
@teams = (@filters.present? && @filters[:teams]&.values) || current_user.teams
@display_query = query
if @search_whole_phrase || query.count(' ').zero?
if query.length < Constants::NAME_MIN_LENGTH
flash[:error] = t('general.query.length_too_short',
min_length: Constants::NAME_MIN_LENGTH)
redirect_back(fallback_location: root_path)
elsif query.length > Constants::TEXT_MAX_LENGTH
flash[:error] = t('general.query.length_too_long',
max_length: Constants::TEXT_MAX_LENGTH)
redirect_back(fallback_location: root_path)
else
@search_query = query
end
else
# splits the search query to validate all entries
splited_query = query.split
@search_query = ''
splited_query.each_with_index do |w, i|
if w.length >= Constants::NAME_MIN_LENGTH &&
w.length <= Constants::TEXT_MAX_LENGTH
@search_query += "#{splited_query[i]} "
end
end
if @search_query.blank?
flash[:error] = t('general.query.wrong_query',
min_length: Constants::NAME_MIN_LENGTH,
max_length: Constants::TEXT_MAX_LENGTH)
redirect_back(fallback_location: root_path)
else
@search_query.strip!
splited_query = query.split
@search_query = ''
splited_query.each_with_index do |w, i|
if w.length >= Constants::NAME_MIN_LENGTH &&
w.length <= Constants::TEXT_MAX_LENGTH
@search_query += "#{splited_query[i]} "
end
end
@search_page = 1 if @search_page < 1
if @search_query.blank?
flash[:error] = t('general.query.wrong_query',
min_length: Constants::NAME_MIN_LENGTH,
max_length: Constants::TEXT_MAX_LENGTH)
redirect_back(fallback_location: root_path)
else
@search_query.strip!
end
end
protected
def generate_search_id
SecureRandom.urlsafe_base64(32)
def search_by_name(model, options = {})
@records = model.search(current_user,
@include_archived,
@search_query,
nil,
teams: @teams,
users: @users,
options: options)
filter_records(model) if @filters.present?
sort_records
paginate_records
end
def search_by_name(model)
model.search(current_user,
true,
@search_query,
@search_page,
nil,
match_case: @search_case,
whole_word: @search_whole_word,
whole_phrase: @search_whole_phrase)
.order(created_at: :desc)
def filter_records(model)
filter_datetime!(model, :created_at) if @filters[:created_at].present?
filter_datetime!(model, :updated_at) if @filters[:updated_at].present?
filter_users!(model) if @filters[:users].present?
end
def count_by_name(model)
model.search(current_user,
true,
@search_query,
Constants::SEARCH_NO_LIMIT,
nil,
match_case: @search_case,
whole_word: @search_whole_word,
whole_phrase: @search_whole_phrase).size
def sort_records
@records = case params[:sort]
when 'atoz'
@records.order(name: :asc)
when 'ztoa'
@records.order(name: :desc)
when 'created_asc'
@records.order(created_at: :asc)
else
@records.order(created_at: :desc)
end
end
def count_by_repository
@repository_search_count =
Rails.cache.fetch("#{@search_id}/repository_search_count",
expires_in: 5.minutes) do
search_count = {}
search_results = Repository.search(current_user,
@search_query,
Constants::SEARCH_NO_LIMIT,
nil,
match_case: @search_case,
whole_word: @search_whole_word,
whole_phrase: @search_whole_phrase)
def paginate_records
@records = if params[:preview] == 'true'
@records.page(params[:page]).per(Constants::GLOBAL_SEARCH_PREVIEW_LIMIT)
else
@records.page(params[:page]).per(Constants::SEARCH_LIMIT)
end
end
current_user.teams.includes(:repositories).each do |team|
team_results = {}
team_results[:team] = team
team_results[:count] = 0
team_results[:repositories] = {}
Repository.accessible_by_teams(team).each do |repository|
repository_results = {}
repository_results[:id] = repository.id
repository_results[:repository] = repository
repository_results[:count] = 0
search_results.each do |result|
repository_results[:count] += result.counter if repository.id == result.id
end
team_results[:repositories][repository.name] = repository_results
team_results[:count] += repository_results[:count]
end
search_count[team.name] = team_results
end
search_count
end
count_total = 0
@repository_search_count.each_value do |team_results|
count_total += team_results[:count]
def filter_datetime!(model, attribute)
model_name = model.model_name.collection
if @filters[attribute][:on].present?
from_date = Time.zone.parse(@filters[attribute][:on]).beginning_of_day.utc
to_date = Time.zone.parse(@filters[attribute][:on]).end_of_day.utc
elsif @filters[attribute][:from].present? && @filters[attribute][:to].present?
from_date = Time.zone.parse(@filters[attribute][:from])
to_date = Time.zone.parse(@filters[attribute][:to])
end
count_total
@records = @records.where("#{model_name}.#{attribute} >= ?", from_date) if from_date.present?
@records = @records.where("#{model_name}.#{attribute} <= ?", to_date) if to_date.present?
end
def current_repository_search_count
@repository_search_count.each_value do |counter|
res = counter[:repositories].values.detect do |rep|
rep[:id] == @repository.id
end
return res[:count] if res && res[:count]
end
end
def count_search_results
@project_search_count = fetch_cached_count Project
@project_folder_search_count = fetch_cached_count ProjectFolder
@experiment_search_count = fetch_cached_count Experiment
@module_search_count = fetch_cached_count MyModule
@result_search_count = fetch_cached_count Result
@tag_search_count = fetch_cached_count Tag
@report_search_count = fetch_cached_count Report
@protocol_search_count = fetch_cached_count Protocol
@step_search_count = fetch_cached_count Step
@checklist_search_count = fetch_cached_count Checklist
@repository_search_count_total = count_by_repository
@asset_search_count = fetch_cached_count Asset
@table_search_count = fetch_cached_count Table
@comment_search_count = fetch_cached_count Comment
@search_results_count = @project_search_count
@search_results_count += @project_folder_search_count
@search_results_count += @experiment_search_count
@search_results_count += @module_search_count
@search_results_count += @result_search_count
@search_results_count += @tag_search_count
@search_results_count += @report_search_count
@search_results_count += @protocol_search_count
@search_results_count += @step_search_count
@search_results_count += @checklist_search_count
@search_results_count += @repository_search_count_total
@search_results_count += @asset_search_count
@search_results_count += @table_search_count
@search_results_count += @comment_search_count
end
def fetch_cached_count(type)
exp = 5.minutes
Rails.cache.fetch(
"#{@search_id}/#{type.name.underscore}_search_count", expires_in: exp
) do
count_by_name type
end
end
def search_projects
@project_results = []
@project_results = search_by_name(Project) if @project_search_count.positive?
@search_count = @project_search_count
end
def search_project_folders
@project_folder_results = []
@project_folder_results = search_by_name(ProjectFolder) if @project_folder_search_count.positive?
@search_count = @project_folder_search_count
end
def search_experiments
@experiment_results = []
@experiment_results = search_by_name(Experiment) if @experiment_search_count.positive?
@search_count = @experiment_search_count
end
def search_modules
@module_results = []
@module_results = search_by_name(MyModule) if @module_search_count.positive?
@search_count = @module_search_count
end
def search_results
@result_results = []
@result_results = search_by_name(Result) if @result_search_count.positive?
@search_count = @result_search_count
end
def search_tags
@tag_results = []
@tag_results = search_by_name(Tag) if @tag_search_count.positive?
@search_count = @tag_search_count
end
def search_reports
@report_results = []
@report_results = search_by_name(Report) if @report_search_count.positive?
@search_count = @report_search_count
end
def search_protocols
@protocol_results = []
@protocol_results = search_by_name(Protocol) if @protocol_search_count.positive?
@search_count = @protocol_search_count
end
def search_steps
@step_results = []
@step_results = search_by_name(Step) if @step_search_count.positive?
@search_count = @step_search_count
end
def search_checklists
@checklist_results = []
@checklist_results = search_by_name(Checklist) if @checklist_search_count.positive?
@search_count = @checklist_search_count
end
def search_repository
@repository = Repository.find_by(id: params[:repository])
unless current_user.teams.include?(@repository.team) || @repository.private_shared_with?(current_user.teams)
render_403
end
@repository_results = []
if @repository_search_count_total.positive?
@repository_results =
Repository.search(current_user, @search_query, @search_page,
@repository,
match_case: @search_case,
whole_word: @search_whole_word,
whole_phrase: @search_whole_phrase)
end
@search_count = current_repository_search_count
end
def search_assets
@asset_results = []
@asset_results = search_by_name(Asset) if @asset_search_count.positive?
@search_count = @asset_search_count
end
def search_tables
@table_results = []
@table_results = search_by_name(Table) if @table_search_count.positive?
@search_count = @table_search_count
end
def search_comments
@comment_results = []
@comment_results = search_by_name(Comment) if @comment_search_count.positive?
@search_count = @comment_search_count
def filter_users!(model)
@records = @records.joins("INNER JOIN activities ON #{model.model_name.collection}.id = activities.subject_id
AND activities.subject_type= '#{model.name}'")
.where('activities.owner_id': @filters[:users]&.values)
end
end

View file

@ -9,9 +9,23 @@ class TeamsController < ApplicationController
before_action :load_vars, only: %i(sidebar export_projects export_projects_modal
disable_tasks_sharing_modal shared_tasks_toggle)
before_action :load_current_folder, only: :sidebar
before_action :check_read_permissions, except: :view_type
before_action :check_read_permissions, except: %i(view_type visible_teams visible_users)
before_action :check_export_projects_permissions, only: %i(export_projects_modal export_projects)
def visible_teams
teams = current_user.teams
render json: teams, each_serializer: TeamSerializer
end
def visible_users
teams = current_user.teams
if params[:teams].present?
teams = teams.where(id: params[:teams])
end
users = User.where(id: teams.joins(:users).select('users.id')).order(:full_name)
render json: users, each_serializer: UserSerializer, user: current_user
end
def sidebar
render json: {
html: render_to_string(

View file

@ -8,7 +8,7 @@ module Users
skip_before_action :verify_authenticity_token
before_action :sign_up_with_provider_enabled?,
only: :linkedin
before_action :check_sso_status, only: %i(customazureactivedirectory okta)
before_action :check_sso_status, only: %i(customazureactivedirectory okta openid_connect)
# You should configure your model like this:
# devise :omniauthable, omniauth_providers: [:twitter]
@ -46,17 +46,7 @@ module Users
if user.blank?
# Create new user and identity
full_name = "#{auth.info.first_name} #{auth.info.last_name}"
user = User.new(full_name: full_name,
initials: generate_initials(full_name),
email: email,
password: generate_user_password)
User.transaction do
user.save!
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
user.update!(confirmed_at: user.created_at)
end
user = create_user_from_auth(email, auth)
sign_in_and_redirect(user, event: :authentication)
elsif provider_conf['auto_link_on_sign_in']
# Link to existing local account
@ -147,16 +137,7 @@ module Users
user = User.find_by(email: auth.info.email.downcase)
if user.blank?
# Create new user and identity
user = User.new(full_name: auth.info.name,
initials: generate_initials(auth.info.name),
email: auth.info.email,
password: generate_user_password)
User.transaction do
user.save!
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
user.update!(confirmed_at: user.created_at)
end
user = create_user_from_auth(email, auth)
else
# Link to existing local account
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
@ -177,6 +158,107 @@ module Users
end
end
def openid_connect
auth = request.env['omniauth.auth']
settings = ApplicationSettings.instance
provider_conf = settings.values['openid_connect']
raise StandardError, 'No matching OpenID Connect AD provider config found' if provider_conf.blank?
return redirect_to connected_accounts_path if current_user
email = auth.info.email
email ||= auth.dig(:extra, :raw_info, :id_token_claims, :emails)&.first
user = User.from_omniauth(auth)
# User found in database so just signing in
return sign_in_and_redirect(user) if user.present?
if email.blank?
# No email in the token so can not link or create user
error_message = I18n.t('devise.openid_connect.errors.no_email')
return redirect_to after_omniauth_failure_path_for(resource_name)
end
user = User.find_by(email: email.downcase)
if user.blank?
# Create new user and identity
user = create_user_from_auth(email, auth)
sign_in_and_redirect(user)
elsif provider_conf['auto_link_on_sign_in']
# Link to existing local account
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
user.update!(confirmed_at: user.created_at) if user.confirmed_at.blank?
sign_in_and_redirect(user)
else
# Cannot do anything with it, so just return an error
error_message = I18n.t('devise.openid_connect.errors.no_local_user_map')
redirect_to after_omniauth_failure_path_for(resource_name)
end
rescue StandardError => e
Rails.logger.error e.message
Rails.logger.error e.backtrace.join("\n")
error_message = I18n.t('devise.openid_connect.errors.failed_to_save') if e.is_a?(ActiveRecord::RecordInvalid)
error_message ||= I18n.t('devise.openid_connect.errors.generic')
redirect_to after_omniauth_failure_path_for(resource_name)
ensure
if error_message
set_flash_message(:alert, :failure, kind: I18n.t('devise.openid_connect.provider_name'), reason: error_message)
else
set_flash_message(:notice, :success, kind: I18n.t('devise.openid_connect.provider_name'))
end
end
def saml
auth = request.env['omniauth.auth']
settings = ApplicationSettings.instance
provider_conf = settings.values['saml']
raise StandardError, 'No matching SAML provider config found' if provider_conf.blank?
return redirect_to connected_accounts_path if current_user
email = auth.info.email
user = User.from_omniauth(auth)
# User found in database so just signing in
return sign_in_and_redirect(user) if user.present?
if email.blank?
# No email in the token so can not link or create user
error_message = I18n.t('devise.saml.errors.no_email')
return redirect_to after_omniauth_failure_path_for(resource_name)
end
user = User.find_by(email: email.downcase)
if user.blank?
user = create_user_from_auth(email, auth)
sign_in_and_redirect(user)
elsif provider_conf['auto_link_on_sign_in']
# Link to existing local account
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
user.update!(confirmed_at: user.created_at) if user.confirmed_at.blank?
sign_in_and_redirect(user)
else
# Cannot do anything with it, so just return an error
error_message = I18n.t('devise.saml.errors.no_local_user_map')
redirect_to after_omniauth_failure_path_for(resource_name)
end
rescue StandardError => e
Rails.logger.error e.message
Rails.logger.error e.backtrace.join("\n")
error_message = I18n.t('devise.saml.errors.failed_to_save') if e.is_a?(ActiveRecord::RecordInvalid)
error_message ||= I18n.t('devise.saml.errors.generic')
redirect_to after_omniauth_failure_path_for(resource_name)
ensure
if error_message
set_flash_message(:alert, :failure, kind: I18n.t('devise.saml.provider_name'), reason: error_message)
else
set_flash_message(:notice, :success, kind: I18n.t('devise.saml.provider_name'))
end
end
# More info at:
# https://github.com/plataformatec/devise#omniauth
@ -213,5 +295,33 @@ module Users
initials = initials.strip.blank? ? 'PLCH' : initials[0..3]
initials
end
def create_user_from_auth(email, auth)
full_name = "#{auth.info.first_name} #{auth.info.last_name}"
user = User.new(full_name: full_name,
initials: generate_initials(full_name),
email: email,
password: generate_user_password)
User.transaction do
user.save!
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
user.update!(confirmed_at: user.created_at)
end
user
end
def create_user_from_auth(email, auth)
full_name = "#{auth.info.first_name} #{auth.info.last_name}"
user = User.new(full_name: full_name,
initials: generate_initials(full_name),
email: email,
password: generate_user_password)
User.transaction do
user.save!
user.user_identities.create!(provider: auth.provider, uid: auth.uid)
user.update!(confirmed_at: user.created_at)
end
user
end
end
end

View file

@ -21,7 +21,7 @@ class Users::PasswordsController < Devise::PasswordsController
if resource.errors.blank?
resource.unlock_access! if unlockable?(resource)
if !resource.two_factor_auth_enabled?
if !two_factor_auth_enabled_for(resource)
flash_message = resource.active_for_authentication? ? :updated : :updated_not_active
set_flash_message!(:notice, flash_message)
resource.after_database_authentication
@ -39,7 +39,11 @@ class Users::PasswordsController < Devise::PasswordsController
protected
def after_resetting_password_path_for(resource)
resource.two_factor_auth_enabled? ? new_session_path(resource_name) : after_sign_in_path_for(resource)
two_factor_auth_enabled_for(resource) ? new_session_path(resource_name) : after_sign_in_path_for(resource)
end
def two_factor_auth_enabled_for(user)
user.two_factor_auth_enabled?
end
# The path used after sending reset password instructions

View file

@ -48,14 +48,9 @@ module Users
render json: {
html: render_to_string(
partial: 'users/settings/user_teams/' \
'destroy_user_team_modal_body',
'destroy_user_team_modal_body',
locals: { user_assignment: @user_assignment },
formats: :html
),
heading: I18n.t(
'users.settings.user_teams.destroy_uo_heading',
user: escape_input(@user_assignment.user.full_name),
team: escape_input(@user_assignment.assignable.name)
)
}
end
@ -63,29 +58,12 @@ module Users
def destroy
# If user is last administrator of team,
# he/she cannot be deleted from it.
invalid =
managing_team_user_roles_collection.include?(@user_assignment.user_role) &&
@user_assignment
.assignable
.user_assignments
.where(user_role: managing_team_user_roles_collection)
.count <= 1
invalid = @user_assignment.last_with_permission?(TeamPermissions::USERS_MANAGE)
unless invalid
begin
@user_assignment.transaction do
# If user leaves on his/her own accord,
# new owner for projects is the first
# administrator of team
if params[:leave]
new_owner =
@user_assignment
.assignable
.user_assignments
.where(user_role: managing_team_user_roles_collection)
.where.not(id: @user_assignment.id)
.first
.user
Activities::CreateActivityService
.call(activity_type: :user_leave_team,
owner: current_user,
@ -95,10 +73,6 @@ module Users
team: @user_assignment.assignable.id
})
else
# Otherwise, the new owner for projects is
# the current user (= an administrator removing
# the user from the team)
new_owner = current_user
Activities::CreateActivityService
.call(activity_type: :remove_user_from_team,
owner: current_user,
@ -110,8 +84,7 @@ module Users
})
end
reset_user_current_team(@user_assignment)
remove_user_from_team!(@user_assignment, new_owner)
@user_assignment.destroy!
end
rescue StandardError => e
Rails.logger.error e.message
@ -119,21 +92,27 @@ module Users
end
end
if !invalid
if params[:leave]
flash[:notice] = I18n.t(
'users.settings.user_teams.leave_flash',
team: @user_assignment.assignable.name
)
flash.keep(:notice)
end
if invalid
render json: @user_assignment.errors, status: :unprocessable_entity
else
flash[:success] = if params[:leave]
I18n.t(
'users.settings.user_teams.leave_flash',
team: @user_assignment.assignable.name
)
else
I18n.t(
'users.settings.user_teams.remove_flash',
user: @user_assignment.user.full_name,
team: @user_assignment.assignable.name
)
end
generate_notification(current_user,
@user_assignment.user,
@user_assignment.assignable,
false)
render json: { status: :ok }
else
render json: @user_assignment.errors, status: :unprocessable_entity
end
end
@ -165,33 +144,6 @@ module Users
user_assignment.user.current_team_id = ids.first
user_assignment.user.save
end
def remove_user_from_team!(user_assignment, new_owner)
return user_assignment.destroy! unless new_owner
# Also, make new owner author of all protocols that belong
# to the departing user and belong to this team.
p_ids = user_assignment.user.added_protocols.where(team: user_assignment.assignable).pluck(:id)
Protocol.where(id: p_ids).find_each do |protocol|
protocol.record_timestamps = false
protocol.added_by = new_owner
protocol.archived_by = new_owner if protocol.archived_by == user_assignment.user
protocol.restored_by = new_owner if protocol.restored_by == user_assignment.user
protocol.save!(validate: false)
protocol.user_assignments.find_by(user: new_owner)&.destroy!
protocol.user_assignments.create!(
user: new_owner,
user_role: UserRole.find_predefined_owner_role,
assigned: :manually
)
end
# Make new owner author of all inventory items that were added
# by departing user and belong to this team.
RepositoryRow.change_owner(user_assignment.assignable, user_assignment.user, new_owner)
user_assignment.destroy!
end
end
end
end

View file

@ -199,12 +199,21 @@ module ApplicationHelper
ENV['SSO_ENABLED'] == 'true'
end
def okta_configured?
ApplicationSettings.instance.values['okta'].present?
def okta_enabled?
ApplicationSettings.instance.values.dig('okta', 'enabled')
end
def azure_ad_configured?
ApplicationSettings.instance.values['azure_ad_apps'].present?
def azure_ad_enabled?
provider_conf = ApplicationSettings.instance.values['azure_ad_apps']
provider_conf.present? && provider_conf[0]['enabled']
end
def saml_enabled?
ApplicationSettings.instance.values.dig('saml', 'enabled')
end
def openid_connect_enabled?
ApplicationSettings.instance.values.dig('openid_connect', 'enabled')
end
def wopi_enabled?
@ -213,7 +222,7 @@ module ApplicationHelper
# Check whether the wopi file can be edited and return appropriate response
def wopi_file_edit_button_status(asset)
file_ext = asset.file_name.split('.').last
file_ext = asset.file_name.split('.').last&.downcase
if Constants::WOPI_EDITABLE_FORMATS.include?(file_ext)
edit_supported = true
title = ''

View file

@ -62,7 +62,7 @@ module FileIconsHelper
# For showing in view/edit icon url (WOPI)
def file_application_url(asset)
file_ext = asset.file_name.split('.').last
file_ext = asset.file_name.split('.').last&.downcase
if Constants::FILE_TEXT_FORMATS.include?(file_ext)
'icon_small/docx_file.svg'
elsif Constants::FILE_TABLE_FORMATS.include?(file_ext)
@ -73,7 +73,7 @@ module FileIconsHelper
end
def sn_icon_for(asset)
file_ext = asset.file_name.split('.').last
file_ext = asset.file_name.split('.').last&.downcase
if Constants::FILE_TEXT_FORMATS.include?(file_ext)
'file-word'
elsif Constants::FILE_TABLE_FORMATS.include?(file_ext)
@ -95,7 +95,7 @@ module FileIconsHelper
# Shows correct WOPI application text (Word Online/Excel ..)
def wopi_button_text(asset, action)
file_ext = asset.file_name.split('.').last
file_ext = asset.file_name.split('.').last&.downcase
if Constants::FILE_TEXT_FORMATS.include?(file_ext)
app = I18n.t('result_assets.wopi_word')
elsif Constants::FILE_TABLE_FORMATS.include?(file_ext)

View file

@ -0,0 +1,10 @@
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import GlobalSearch from '../../vue/global_search/container.vue';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
const app = createApp();
app.component('global_search', GlobalSearch);
app.config.globalProperties.i18n = window.I18n;
app.use(PerfectScrollbar);
mountWithTurbolinks(app, '#GlobalSearch');

View file

@ -0,0 +1,12 @@
/* global */
import PerfectScrollbar from 'vue3-perfect-scrollbar';
import { createApp } from 'vue/dist/vue.esm-bundler.js';
import { mountWithTurbolinks } from './helpers/turbolinks.js';
import RepositoryItemErrorSidebar from '../../vue/repository_item_sidebar/RepositoryItemErrorSidebar.vue';
const app = createApp({});
app.component('RepositoryItemErrorSidebar', RepositoryItemErrorSidebar);
app.use(PerfectScrollbar);
app.config.globalProperties.i18n = window.I18n;
mountWithTurbolinks(app, '#repositoryItemErrorSidebar');

View file

@ -1,6 +1,6 @@
<template>
<div class="px-3 pt-3 pb-4 rounded border-solid border border-sn-gray flex flex-col"
:class="{ 'bg-sn-light-grey': dtComponent.currentViewMode === 'archived', [cardMinWidth]: true}">
<div class="px-3 pt-3 pb-4 rounded border-solid border border-sn-grey-300 flex flex-col"
:class="{ 'bg-sn-grey-100': dtComponent.currentViewMode === 'archived', [cardMinWidth]: true}">
<div class="flex items-center gap-4 mb-2">
<div class="sci-checkbox-container">
<input
@ -16,7 +16,10 @@
</div>
<a :href="params.urls.show"
:title="params.name"
:class="{'pointer-events-none text-sn-grey': !params.urls.show}"
:class="{
'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">
{{ params.name }}
</a>
@ -89,7 +92,8 @@ export default {
progress() {
const { completed_tasks: completedTasks, total_tasks: totalTasks } = this.params;
if (totalTasks === 0) return 0;
if (totalTasks === 0) return 3;
if (completedTasks === 0) return 3;
return (completedTasks / totalTasks) * 100;
},

View file

@ -10,7 +10,9 @@
{{ experiment.name }}
</h4>
</div>
<div class="modal-body" v-html="experiment.sa_description"></div>
<div class="modal-body">
<div class="[&_.atwho-user-container]:!whitespace-normal whitespace-pre-wrap" v-html="experiment.sa_description"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{{ i18n.t('general.close') }}</button>
</div>

View file

@ -33,7 +33,8 @@ export default {
progress() {
const { completed_tasks: completedTasks, total_tasks: totalTasks } = this.params.data;
if (totalTasks === 0) return 0;
if (totalTasks === 0) return 3;
if (completedTasks === 0) return 3;
return (completedTasks / totalTasks) * 100;
}

View file

@ -1,32 +1,32 @@
<template>
<div class="group relative flex items-center group-hover:marker text-xs h-full w-full"
:style="{ lineHeight: 'unset' }">
<div class="flex gap-2"
:style="{ lineHeight: 'unset' }"
:class="{
'items-center text-sm': params.dtComponent.currentViewRender === 'table',
'items-end text-xs': params.dtComponent.currentViewRender === 'cards'
}">
<span v-if="shouldTruncateText"
class="cursor-pointer grow"
:class="{
'line-clamp-1': params.dtComponent.currentViewRender === 'table',
'line-clamp-2': params.dtComponent.currentViewRender === 'cards'
}"
<template v-if="params.dtComponent.currentViewRender === 'table'">
<div class="group relative flex items-center group-hover:marker text-xs h-full w-full leading-[unset]">
<div class="flex gap-2 w-full items-center text-sm leading-[unset]">
<span class="cursor-pointer line-clamp-1 leading-[unset]"
@click.stop="showDescriptionModal"
v-html="params.data.sa_description">
</span>
<span v-else class="grow" v-html="params.data.sa_description"></span>
<span v-if="shouldTruncateText" @click.stop="showDescriptionModal" class="text-sn-blue cursor-pointer shrink-0 inline-block"
:style="{ lineHeight: 'unset' }"
:class="{
'text-xs': params.dtComponent.currentViewRender === 'cards',
'text-sm': params.dtComponent.currentViewRender === 'table'
}">
<span @click.stop="showDescriptionModal" class="text-sn-blue cursor-pointer shrink-0 inline-block text-sm">
{{ i18n.t('experiments.card.more') }}
</span>
</div>
</div>
</template>
<template v-else>
<div class="group relative flex items-center group-hover:marker text-xs h-full w-full">
<div class="flex gap-2 w-full items-end text-xs">
<span v-if="shouldTruncateText"
class="cursor-pointer grow line-clamp-2"
@click.stop="showDescriptionModal"
v-html="params.data.sa_description">
</span>
<span v-else class="grow" v-html="params.data.sa_description"></span>
<span v-if="shouldTruncateText" @click.stop="showDescriptionModal" class="text-sn-blue cursor-pointer shrink-0 inline-block text-xs">
{{ i18n.t('experiments.card.more') }}
</span>
</div>
</div>
</template>
</template>
<script>
@ -39,7 +39,7 @@ export default {
},
computed: {
shouldTruncateText() {
return this.params.data.description?.length > 80;
return this.params.data.description?.length > 60;
}
},
methods: {

View file

@ -0,0 +1,319 @@
<template>
<div class="content-pane flexible with-grey-background">
<div class="content-header">
<div class="title-row">
<h1 class="mt-0">
{{ i18n.t('search.index.results_title_html', { query: localQuery }) }}
</h1>
</div>
</div>
<div class="bg-white rounded p-4 flex gap-2.5 z-10 items-center mb-4 sticky top-0">
<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}">
<input ref="searchField"
type="text"
class="!pr-9"
:value="localQuery"
@change="changeQuery"
@keydown.enter="changeQuery"
@blur="changeQuery"
:placeholder="i18n.t('nav.search')"
/>
<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>
</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>
</template>
</GeneralDropdown>
<div class="flex items-center gap-2.5">
<button class="btn btn-secondary btn-sm" :class="{'active': activeGroup == 'ExperimentsComponent'}" @click="setActiveGroup('ExperimentsComponent')">
{{ i18n.t('search.index.experiments') }}
</button>
<button class="btn btn-secondary btn-sm" :class="{'active': activeGroup == 'MyModulesComponent'}" @click="setActiveGroup('MyModulesComponent')">
{{ i18n.t('search.index.tasks') }}
</button>
<button class="btn btn-secondary btn-sm" :class="{'active': activeGroup == 'ResultsComponent'}" @click="setActiveGroup('ResultsComponent')">
{{ 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>
<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]"
>
{{ activeFilters.length }}
</span>
</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>
</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>
</div>
<template v-for="group in searchGroups">
<component
ref="groupComponents"
:key="group"
:is="group"
v-if="activeGroup === group || !activeGroup"
:selected="activeGroup === group"
:query="localQuery"
:searchUrl="searchUrl"
:filters="filters"
@selectGroup="setActiveGroup"
@updated="calculateTotalElements"
/>
</template>
<div v-if="totalElements === 0" class="bg-white rounded p-4">
<NoSearchResult />
</div>
<teleport to='body'>
<FiltersModal
v-if="filterModalOpened"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
:filters="filters"
:currentTeam="currentTeam"
@search="applyFilters"
@close="filterModalOpened = false"
/>
</teleport>
</div>
</template>
<script>
import FoldersComponent from './groups/folders.vue';
import ProjectsComponent from './groups/projects.vue';
import ExperimentsComponent from './groups/experiments.vue';
import MyModulesComponent from './groups/my_modules.vue';
import MyModuleProtocolsComponent from './groups/my_module_protocols.vue';
import ResultsComponent from './groups/results.vue';
import AssetsComponent from './groups/assets.vue';
import RepositoryRowsComponent from './groups/repository_rows.vue';
import ProtocolsComponent from './groups/protocols.vue';
import LabelTemplatesComponent from './groups/label_templates.vue';
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';
export default {
emits: ['search', 'selectGroup'],
name: 'GlobalSearch',
props: {
query: {
type: String,
required: true
},
searchUrl: {
type: String,
required: true
},
teamsUrl: {
type: String,
required: true
},
usersUrl: {
type: String,
required: true
},
currentTeam: {
type: Number || String,
required: true
}
},
components: {
FoldersComponent,
ProjectsComponent,
ExperimentsComponent,
MyModulesComponent,
MyModuleProtocolsComponent,
ResultsComponent,
AssetsComponent,
RepositoryRowsComponent,
ProtocolsComponent,
LabelTemplatesComponent,
ReportsComponent,
FiltersModal,
GeneralDropdown,
NoSearchResult
},
data() {
return {
filters: {},
localQuery: this.query,
filterModalOpened: false,
previousQueries: [],
invalidQuery: false,
activeGroup: null,
totalElements: 0,
searchGroups: [
'FoldersComponent',
'ProjectsComponent',
'ExperimentsComponent',
'MyModulesComponent',
'MyModuleProtocolsComponent',
'ResultsComponent',
'AssetsComponent',
'RepositoryRowsComponent',
'ProtocolsComponent',
'LabelTemplatesComponent',
'ReportsComponent'
]
};
},
computed: {
activeFilters() {
return Object.keys(this.filters).filter((key) => {
if (key === 'created_at' || key === 'updated_at') {
return this.filters[key].on || this.filters[key].from || this.filters[key].to;
} if (key === 'teams' || key === 'users') {
return this.filters[key].length > 0;
}
return this.filters[key];
});
},
canOpenHistory() {
return this.previousQueries.length > 0 && this.localQuery.length === 0;
},
reversedPreviousQueries() {
return [...this.previousQueries].reverse();
}
},
created() {
const urlParams = new URLSearchParams(window.location.search);
this.filters = {
created_at: {
on: null,
from: null,
to: null
},
updated_at: {
on: null,
from: null,
to: null
},
include_archived: urlParams.get('include_archived') === 'true',
teams: urlParams.getAll('teams[]').map((team) => parseInt(team, 10)),
users: urlParams.getAll('users[]').map((user) => parseInt(user, 10)),
group: urlParams.get('group')
};
['created_at', 'updated_at'].forEach((key) => {
['on', 'from', 'to', 'mode'].forEach((subKey) => {
if (urlParams.get(`${key}[${subKey}]`)) {
this.filters[key][subKey] = subKey !== 'mode' ? new Date(urlParams.get(`${key}[${subKey}]`)) : urlParams.get(`${key}[${subKey}]`);
}
});
});
if (this.filters.group) {
this.activeGroup = this.filters.group;
}
this.previousQueries = JSON.parse(localStorage.getItem('quickSearchHistory') || '[]');
},
methods: {
calculateTotalElements() {
let total = 0;
if (this.$refs.groupComponents) {
this.$refs.groupComponents.forEach((group) => {
total += group.total;
});
}
this.totalElements = total;
},
setActiveGroup(group) {
if (group === this.activeGroup) {
this.activeGroup = null;
} else {
this.activeGroup = group;
}
this.filters.group = this.activeGroup;
},
setQuery(query) {
this.localQuery = query;
this.invalidQuery = false;
this.$refs.historyContainer.isOpen = false;
},
changeQuery(event) {
if (event.target.value === this.localQuery) {
return;
}
this.localQuery = event.target.value;
if (event.target.value.length < 2) {
this.invalidQuery = true;
return;
}
this.invalidQuery = false;
this.saveQuery();
},
saveQuery() {
if (this.localQuery.length > 1) {
if (this.previousQueries[this.previousQueries.length - 1] === this.localQuery) return;
this.previousQueries.push(this.localQuery);
if (this.previousQueries.length > 5) {
this.previousQueries.shift();
}
localStorage.setItem('quickSearchHistory', JSON.stringify(this.previousQueries));
this.$refs.historyContainer.isOpen = false;
}
},
applyFilters(filters) {
this.filters = filters;
this.filterModalOpened = false;
this.activeGroup = this.filters.group;
},
resetGroup() {
this.activeGroup = null;
this.filters.group = null;
},
resetFilters() {
this.filters = {
created_at: {
on: null,
from: null,
to: null
},
updated_at: {
on: null,
from: null,
to: null
},
include_archived: false,
teams: [],
users: [],
group: null
};
this.activeGroup = null;
}
}
};
</script>

View file

@ -0,0 +1,234 @@
<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 items-center gap-2 flex-wrap mb-6">
<template v-for="group in searchGroups" :key="group.value">
<button class="btn btn-secondary btn-xs"
:class="{'active': activeGroup === group.value}"
@click="setActiveGroup(group.value)">
{{ group.label }}
</button>
</template>
</div>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_created_date') }}</div>
<DateFilter
:date="createdAt"
ref="createdAtComponent"
class="mb-6"
@change="(v) => {this.createdAt = v}"
></DateFilter>
<div class="sci-label mb-2">{{ i18n.t('search.filters.by_updated_date') }}</div>
<DateFilter
:date="updatedAt"
ref="updatedAtComponent"
class="mb-6"
@change="(v) => {this.updatedAt = v}"
></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">
{{ i18n.t('search.filters.by_user') }}
<i class="sn-icon sn-icon-info" :title="i18n.t('search.filters.by_user_info')"></i>
</div>
<SelectDropdown :options="users"
class="mb-6"
:value="selectedUsers"
:optionRenderer="userRenderer"
:labelRenderer="userRenderer"
:clearable="true"
:with-checkboxes="true"
:multiple="true"
@change="(v) => {selectedUsers = v}" />
<div class="flex items-center gap-2">
<div class="sci-checkbox-container">
<input type="checkbox" v-model="includeArchived" class="sci-checkbox" />
<span class="sci-checkbox-label"></span>
</div>
{{ i18n.t('search.filters.include_archived') }}
</div>
</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>
</div>
</div>
</template>
<script>
import DateFilter from './filters/date.vue';
import SelectDropdown from '../shared/select_dropdown.vue';
import axios from '../../packs/custom_axios.js';
export default {
name: 'SearchFilters',
props: {
teamsUrl: {
type: String,
required: true
},
usersUrl: {
type: String,
required: true
},
filters: Object,
currentTeam: Number || String,
searchUrl: String,
searchQuery: String
},
created() {
this.fetchTeams();
if (this.currentTeam) {
this.selectedTeams = [this.currentTeam];
}
if (this.filters) {
this.createdAt = this.filters.created_at;
this.updatedAt = this.filters.updated_at;
this.selectedTeams = this.filters.teams;
this.$nextTick(() => {
this.selectedUsers = this.filters.users;
});
this.includeArchived = this.filters.include_archived;
this.activeGroup = this.filters.group;
}
},
watch: {
selectedTeams() {
this.selectedUsers = [];
this.fetchUsers();
}
},
data() {
return {
activeGroup: null,
createdAt: {
on: null,
from: null,
to: null
},
updatedAt: {
on: null,
from: null,
to: null
},
selectedTeams: [],
selectedUsers: [],
includeArchived: true,
teams: [],
users: [],
searchGroups: [
{ value: 'FoldersComponent', label: this.i18n.t('search.index.folders') },
{ value: 'ProjectsComponent', label: this.i18n.t('search.index.projects') },
{ value: 'ExperimentsComponent', label: this.i18n.t('search.index.experiments') },
{ value: 'MyModulesComponent', label: this.i18n.t('search.index.tasks') },
{ value: 'MyModuleProtocolsComponent', label: this.i18n.t('search.index.task_protocols') },
{ value: 'ResultsComponent', label: this.i18n.t('search.index.task_results') },
{ value: 'AssetsComponent', label: this.i18n.t('search.index.files') },
{ value: 'RepositoryRowsComponent', label: this.i18n.t('search.index.inventory_items') },
{ value: 'ProtocolsComponent', label: this.i18n.t('search.index.protocol_templates') },
{ value: 'LabelTemplatesComponent', label: this.i18n.t('search.index.label_templates') },
{ value: 'ReportsComponent', label: this.i18n.t('search.index.reports') }
]
};
},
components: {
DateFilter,
SelectDropdown
},
methods: {
userRenderer(option) {
return `<div class="flex items-center gap-2">
<img src="${option[2].avatar_url}" class="rounded-full w-6 h-6" />
<div title="${option[1]}" class="truncate">${option[1]}</div>
</div>`;
},
setActiveGroup(group) {
if (group === this.activeGroup) {
this.activeGroup = null;
} else {
this.activeGroup = group;
}
},
fetchTeams() {
axios.get(this.teamsUrl)
.then((response) => {
this.teams = response.data.data.map((team) => ([parseInt(team.id, 10), team.attributes.name]));
});
},
fetchUsers() {
axios.get(this.usersUrl, { params: { teams: this.selectedTeams } })
.then((response) => {
this.users = response.data.data.map((user) => ([parseInt(user.id, 10), user.attributes.name, { avatar_url: user.attributes.avatar_url }]));
});
},
clearFilters() {
this.createdAt = {
on: null,
from: null,
to: null
};
this.updatedAt = {
on: null,
from: null,
to: null
};
this.$refs.createdAtComponent.selectedOption = 'on';
this.$refs.updatedAtComponent.selectedOption = 'on';
this.selectedTeams = [];
this.selectedUsers = [];
this.includeArchived = false;
this.activeGroup = null;
},
search() {
if (this.searchUrl) {
this.openSearchPage();
} else {
this.$emit('search', {
created_at: this.createdAt,
updated_at: this.updatedAt,
teams: this.selectedTeams,
users: this.selectedUsers,
include_archived: this.includeArchived,
group: this.activeGroup
});
}
},
openSearchPage() {
const params = {
'created_at[on]': this.createdAt.on || '',
'created_at[from]': this.createdAt.from || '',
'created_at[to]': this.createdAt.to || '',
'created_at[mode]': this.createdAt.mode || '',
'updated_at[on]': this.updatedAt.on || '',
'updated_at[from]': this.updatedAt.from || '',
'updated_at[to]': this.updatedAt.to || '',
'updated_at[mode]': this.updatedAt.mode || '',
include_archived: this.includeArchived,
group: this.activeGroup || '',
q: this.searchQuery
};
const searchParams = new URLSearchParams(params);
this.selectedTeams.forEach((team) => {
searchParams.append('teams[]', team);
});
this.selectedUsers.forEach((user) => {
searchParams.append('users[]', user);
});
window.location.href = `${this.searchUrl}?${searchParams.toString()}`;
}
}
};
</script>

View file

@ -0,0 +1,152 @@
<template>
<div class="flex gap-2">
<SelectDropdown class="!w-40"
:options="dateOptions"
:value="selectedOption"
@change="(v) => {selectedOption = v}" />
<div class="grow">
<DateTimePicker
v-if="selectedOption === 'on'"
@change="setOn"
mode="date"
size="mb"
placeholder="Enter date"
:defaultValue="date.on"
:clearable="true"/>
<DateTimePicker
v-if="selectedOption === 'custom'"
@change="setFrom"
class="mb-2"
mode="date"
size="mb"
placeholder="From date"
:defaultValue="date.from"
:clearable="true"/>
<DateTimePicker
v-if="selectedOption === 'custom'"
@change="setTo"
mode="date"
size="mb"
placeholder="To date"
:defaultValue="date.to"
:clearable="true"/>
</div>
</div>
</template>
<script>
import SelectDropdown from '../../shared/select_dropdown.vue';
import DateTimePicker from '../../shared/date_time_picker.vue';
export default {
name: 'DateFilter',
props: {
date: {
type: Object,
required: true
}
},
components: {
SelectDropdown,
DateTimePicker
},
watch: {
selectedOption() {
const today = new Date();
const yesterday = new Date(new Date().setDate(today.getDate() - 1));
const weekDay = today.getDay();
const monday = new Date(new Date()
.setDate(today.getDate() - weekDay - (weekDay === 0 ? 6 : -1)));
const lastWeekStart = new Date(monday.getTime() - (7 * 24 * 60 * 60 * 1000));
const lastWeekEnd = new Date(lastWeekStart.getTime() + (6 * 24 * 60 * 60 * 1000));
const firstMonthDay = new Date(today.getFullYear(), today.getMonth(), 1);
const firstYearDay = new Date(today.getFullYear(), 0, 1);
const lastYearEnd = new Date(today.getFullYear(), 0, 0);
const lastYearStart = new Date(today.getFullYear() - 1, 0, 1);
switch (this.selectedOption) {
case 'today':
this.newDate = {
on: today, from: null, to: null, mode: 'today'
};
break;
case 'yesterday':
this.newDate = {
on: yesterday, from: null, to: null, mode: 'yesterday'
};
break;
case 'last_week':
this.newDate = {
on: null, from: lastWeekStart, to: lastWeekEnd, mode: 'last_week'
};
break;
case 'this_month':
this.newDate = {
on: null, from: firstMonthDay, to: today, mode: 'this_month'
};
break;
case 'this_year':
this.newDate = {
on: null, from: firstYearDay, to: today, mode: 'this_year'
};
break;
case 'last_year':
this.newDate = {
on: null, from: lastYearStart, to: lastYearEnd, mode: 'last_year'
};
break;
case 'on':
this.newDate = {
on: null, from: null, to: null, mode: 'on'
};
break;
case 'custom':
this.newDate = {
on: null, from: null, to: null, mode: 'custom'
};
break;
default:
break;
}
this.$emit('change', this.newDate);
}
},
data() {
return {
newDate: this.date,
selectedOption: (this.date.mode || 'on'),
dateOptions: [
['today', this.i18n.t('search.filters.date.today')],
['yesterday', this.i18n.t('search.filters.date.yesterday')],
['last_week', this.i18n.t('search.filters.date.last_week'), { tooltip: this.i18n.t('search.filters.date.last_week_tooltip') }],
['this_month', this.i18n.t('search.filters.date.this_month'), { tooltip: this.i18n.t('search.filters.date.this_month_tooltip') }],
['this_year', this.i18n.t('search.filters.date.this_year'), { tooltip: this.i18n.t('search.filters.date.this_year_tooltip') }],
['last_year', this.i18n.t('search.filters.date.last_year'), { tooltip: this.i18n.t('search.filters.date.last_year_tooltip') }],
['on', this.i18n.t('search.filters.date.on')],
['custom', this.i18n.t('search.filters.date.custom')]
]
};
},
methods: {
setOn(v) {
this.newDate = {
on: v, from: null, to: null, mode: 'on'
};
this.$emit('change', this.newDate);
},
setFrom(v) {
this.newDate.mode = 'custom';
this.newDate.on = null;
this.newDate.from = v;
this.$emit('change', this.newDate);
},
setTo(v) {
this.newDate.mode = 'custom';
this.newDate.on = null;
this.newDate.to = v;
this.$emit('change', this.newDate);
}
}
};
</script>

View file

@ -0,0 +1,49 @@
<template>
<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-header flex-wrap">
<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('search.filters.title') }}
</h4>
<div class="basis-full">
{{ i18n.t('search.filters.sub_title') }}
</div>
</div>
<div class="modal-body !pb-0 !pt-2.5">
<Filters
:teams-url="teamsUrl"
:users-url="usersUrl"
:filters="filters"
:currentTeam="currentTeam"
@search="(newFilters) => { this.$emit('search', newFilters); }"
@cancel="close" />
</div>
</div>
</form>
</div>
</div>
</template>
<script>
import modalMixin from '../shared/modal_mixin';
import Filters from './filters.vue';
export default {
name: 'FiltersModal',
props: {
teamsUrl: String,
usersUrl: String,
filters: Object,
currentTeam: Number || String
},
components: {
Filters
},
mixins: [modalMixin]
};
</script>

View file

@ -0,0 +1,50 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-files"></i>
{{ i18n.t('search.index.files') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_auto_auto_auto_auto_auto] items-center">
<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"/>
<CellTemplate :label="i18n.t('search.index.created_at')" :value="row.attributes.created_at"/>
<CellTemplate :label=" i18n.t('search.index.updated_at')" :value="row.attributes.updated_at"/>
<CellTemplate :label="i18n.t(`search.index.${row.attributes.parent.type}`)" :url="row.attributes.parent.url" :value="labelName(row.attributes.parent)"/>
<CellTemplate v-if="row.attributes.repository.name" :label="i18n.t(`search.index.repository`)"
:url="row.attributes.repository.url" :value="labelName(row.attributes.repository)"/>
<CellTemplate v-else-if="row.attributes.experiment.name" :label="i18n.t(`search.index.experiment`)"
:url="row.attributes.experiment.url" :value="labelName(row.attributes.experiment)"/>
<div v-else></div>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'AssetsComponent',
mixins: [searchMixin],
data() {
return {
group: 'assets'
};
}
};
</script>

View file

@ -0,0 +1,45 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-experiment"></i>
{{ i18n.t('search.index.experiments') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto] items-center">
<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})"/>
<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.project')" :url="row.attributes.project.url" :value="labelName(row.attributes.project)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'ExperimentsComponent',
mixins: [searchMixin],
data() {
return {
group: 'experiments'
};
}
};
</script>

View file

@ -0,0 +1,45 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-folder"></i>
{{ i18n.t('search.index.folders') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_auto_auto_auto] items-center">
<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">
<LinkTemplate :url="row.attributes.url" :value="labelName({ name: row.attributes.name, archived: row.attributes.archived})"/>
<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)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'FoldersComponent',
mixins: [searchMixin],
data() {
return {
group: 'project_folders'
};
}
};
</script>

View file

@ -0,0 +1,33 @@
<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">
<template v-if="visible">
<b class="shrink-0">{{ 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>
</a>
<div v-else class="grid grid-cols-[auto_1fr] items-center gap-1 overflow-hidden">
<img v-if="avatar" :src="avatar" class="w-5 h-5 border border-sn-super-light-grey rounded-full mx-1" />
<span class="shrink-0 truncate" :title="value">{{ value }}</span>
</div>
</template>
</div>
</template>
<script>
import StringWithEllipsis from '../../../shared/string_with_ellipsis.vue';
export default {
name: 'CellTemplate',
props: {
label: { type: String, default: '' },
value: { type: String, default: '' },
url: { type: String, default: '' },
avatar: { type: String, default: '' },
visible: { type: Boolean, default: true }
},
components: {
StringWithEllipsis
}
};
</script>

View file

@ -0,0 +1,30 @@
<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"
>
<span v-if="icon" :class="icon" class="sn-icon shrink-0"></span>
<StringWithEllipsis
:class="{
'w-full': !icon,
'w-[calc(100%-2rem)]': icon
}"
:text="value">
</StringWithEllipsis>
</a>
</template>
<script>
import StringWithEllipsis from '../../../shared/string_with_ellipsis.vue';
export default {
name: 'LinkTemplate',
props: {
value: { type: String, default: '' },
url: { type: String, default: '' },
icon: { type: String, default: '' }
},
components: {
StringWithEllipsis
}
};
</script>

View file

@ -0,0 +1,17 @@
<template>
<div class="flex flex-col gap-6 mt-6">
<div class="flex items-center mb-6">
<p class="text-sm text-sn-blue flex items-center gap-3 m-auto px-4 py-2 rounded bg-sn-super-light-blue">
<span class="sn-icon sn-icon-flag"></span>
<span>{{ i18n.t('search.index.reached_end') }}</span>
</p>
</div>
</div>
</template>
<script>
export default {
name: 'ListEnd'
};
</script>

View file

@ -0,0 +1,22 @@
<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 class="">
<p class="text-sn-black text-2xl font-semibold">
{{ i18n.t('search.index.no_results_text') }}
</p>
<p class="text-sn-dark-grey text-base">
{{ i18n.t('search.index.adjust_search_text') }}
</p>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'NoSearchResult'
};
</script>

View file

@ -0,0 +1,43 @@
<template>
<MenuDropdown
class="ml-auto"
:listItems="sortOptions"
btnClasses="btn btn-light icon-btn btn-black"
position="right"
@dtEvent="changeSort"
btnIcon="sn-icon sn-icon-sort-down"
></MenuDropdown>
</template>
<script>
import MenuDropdown from '../../../shared/menu_dropdown.vue';
export default {
name: 'SortFlyout',
props: {
sort: {
type: String,
default: 'created_desc'
}
},
components: {
MenuDropdown
},
computed: {
sortOptions() {
return ['created_desc', 'created_asc', 'atoz', 'ztoa'].map((sort) => (
{
emit: sort,
text: this.i18n.t(`search.index.${sort}`),
active: this.sort === sort
}
));
}
},
methods: {
changeSort(value) {
this.$emit('changeSort', value);
}
}
};
</script>

View file

@ -0,0 +1,46 @@
<template>
<div v-if="!disabled" ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-label-templates"></i>
{{ i18n.t('search.index.label_templates') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<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"/>
<CellTemplate :label="i18n.t('search.index.format')" :value="row.attributes.format"/>
<CellTemplate :label="i18n.t('search.index.created_at')" :value="row.attributes.created_at"/>
<CellTemplate :label="i18n.t('search.index.updated_at')" :value="row.attributes.updated_at"/>
<CellTemplate :label="i18n.t('search.index.created_by')" :avatar="row.attributes.created_by.avatar_url" :value="row.attributes.created_by.name"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'LabelTemplatesComponent',
mixins: [searchMixin],
data() {
return {
group: 'label_templates'
};
}
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-protocols-templates"></i>
{{ i18n.t('search.index.task_protocols') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto_auto] items-center">
<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})"/>
<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.updated_at')" :value="row.attributes.updated_at"/>
<CellTemplate :label="i18n.t('search.index.task')" :url="row.attributes.my_module.url" :value="labelName(row.attributes.my_module)"/>
<CellTemplate :label="i18n.t('search.index.experiment')" :url="row.attributes.experiment.url" :value="labelName(row.attributes.experiment)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'MyModuleProtocolsComponent',
mixins: [searchMixin],
data() {
return {
group: 'module_protocols'
};
}
};
</script>

View file

@ -0,0 +1,46 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-task"></i>
{{ i18n.t('search.index.tasks') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<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})"/>
<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.updated_at')" :value="row.attributes.updated_at"/>
<CellTemplate :label="i18n.t('search.index.experiment')" :url="row.attributes.experiment.url" :value="labelName(row.attributes.experiment)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'MyModulesComponent',
mixins: [searchMixin],
data() {
return {
group: 'tasks'
};
}
};
</script>

View file

@ -0,0 +1,46 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-projects"></i>
{{ i18n.t('search.index.projects') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto] items-center">
<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})"/>
<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.folder"
:url="row.attributes.folder?.url" :value="labelName(row.attributes.folder)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'ProjectsComponent',
mixins: [searchMixin],
data() {
return {
group: 'projects'
};
}
};
</script>

View file

@ -0,0 +1,46 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-protocols-templates"></i>
{{ i18n.t('search.index.protocol_templates') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<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})"/>
<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.updated_at')" :value="row.attributes.updated_at"/>
<CellTemplate :label="i18n.t('search.index.created_by')" :avatar="row.attributes.created_by.avatar_url" :value="row.attributes.created_by.name"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'ProtocolsComponent',
mixins: [searchMixin],
data() {
return {
group: 'protocols'
};
}
};
</script>

View file

@ -0,0 +1,47 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-reports"></i>
{{ i18n.t('search.index.reports') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto_auto] items-center">
<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"/>
<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.updated_at')" :value="row.attributes.updated_at"/>
<CellTemplate :label="i18n.t('search.index.created_by')" :avatar="row.attributes.created_by.avatar_url" :value="row.attributes.created_by.name"/>
<CellTemplate :label="i18n.t('search.index.project')" :url="row.attributes.project.url" :value="labelName(row.attributes.project)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'ReportsComponent',
mixins: [searchMixin],
data() {
return {
group: 'reports'
};
}
};
</script>

View file

@ -0,0 +1,46 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-inventory"></i>
{{ i18n.t('search.index.inventory_items') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_110px_auto_auto_auto_auto] items-center">
<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})"/>
<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.created_by')" :avatar="row.attributes.created_by.avatar_url" :value="row.attributes.created_by.name"/>
<CellTemplate :label="i18n.t('search.index.repository')" :url="row.attributes.repository.url" :value="labelName(row.attributes.repository)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'RepositoryRowsComponent',
mixins: [searchMixin],
data() {
return {
group: 'repository_rows'
};
}
};
</script>

View file

@ -0,0 +1,46 @@
<template>
<div ref="content" class="bg-white rounded" :class="{ 'p-4 mb-4': results.length || loading }">
<template v-if="total && results.length">
<div class="flex items-center">
<h2 class="flex items-center gap-2 mt-0 mb-4">
<i class="sn-icon sn-icon-results"></i>
{{ i18n.t('search.index.task_results') }}
<span class="text-base" >[{{ total }}]</span>
</h2>
<SortFlyout v-if="selected" :sort="sort" @changeSort="changeSort"></SortFlyout>
</div>
<div class="grid grid-cols-[auto_auto_auto_auto_auto_auto] items-center">
<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})"/>
<CellTemplate :label="i18n.t('search.index.created_at')" :value="row.attributes.created_at"/>
<CellTemplate :label="i18n.t('search.index.updated_at')" :value="row.attributes.updated_at"/>
<CellTemplate :label="i18n.t('search.index.task')" :url="row.attributes.my_module.url" :value="labelName(row.attributes.my_module)"/>
<CellTemplate :label="i18n.t('search.index.experiment')" :url="row.attributes.experiment.url" :value="labelName(row.attributes.experiment)"/>
<CellTemplate :label="i18n.t('search.index.team')" :url="row.attributes.team.url" :value="row.attributes.team.name"/>
</div>
</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>
</div>
</template>
<Loader v-if="loading" :loaderRows="loaderRows" />
<ListEnd v-if="reachedEnd && preparedResults.length > 0" />
<NoSearchResult v-else-if="showNoSearchResult" />
</div>
</template>
<script>
import searchMixin from './search_mixin';
export default {
name: 'ResultsComponent',
mixins: [searchMixin],
data() {
return {
group: 'results'
};
}
};
</script>

View file

@ -0,0 +1,140 @@
import axios from '../../../packs/custom_axios.js';
import StringWithEllipsis from '../../shared/string_with_ellipsis.vue';
import SortFlyout from './helpers/sort_flyout.vue';
import Loader from '../loader.vue';
import ListEnd from './helpers/list_end.vue';
import NoSearchResult from './helpers/no_search_result.vue';
import CellTemplate from './helpers/cell_template.vue';
import LinkTemplate from './helpers/link_template.vue';
/* global GLOBAL_CONSTANTS I18n */
export default {
props: {
searchUrl: String,
query: String,
selected: Boolean,
filters: Object
},
components: {
StringWithEllipsis,
SortFlyout,
Loader,
NoSearchResult,
ListEnd,
CellTemplate,
LinkTemplate
},
data() {
return {
sort: 'created_desc',
results: [],
total: 0,
loading: false,
page: 1,
disabled: false,
fullDataLoaded: false,
};
},
watch: {
filters() {
this.reloadData();
},
selected() {
if (this.selected && !this.fullDataLoaded) {
this.reloadData();
}
},
query() {
this.reloadData();
}
},
mounted() {
this.loadData();
window.addEventListener('scroll', this.handleScroll);
},
unmounted() {
window.removeEventListener('scroll', this.handleScroll);
},
computed: {
preparedResults() {
if (this.selected) {
return this.results;
}
return this.results.slice(0, 4);
},
viewAll() {
return !this.selected && this.total > GLOBAL_CONSTANTS.GLOBAL_SEARCH_PREVIEW_LIMIT;
},
loaderRows() {
return !this.selected ? 4 : 20;
},
reachedEnd() {
return !this.page && this.selected;
},
showNoSearchResult() {
return this.selected && !this.loading && !this.results.length;
}
},
methods: {
labelName(object) {
if (!object) return '';
if (!object.archived) return object.name;
return `${I18n.t('labels.archived')} ${object.name}`;
},
handleScroll() {
if (this.loading || !this.selected) return;
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
if (this.results.length < this.total) {
this.loadData();
}
}
},
changeSort(sort) {
this.sort = sort;
this.results = [];
this.page = 1;
this.loadData();
},
reloadData() {
if (this.query.length > 1) {
this.results = [];
this.page = 1;
this.total = 0;
this.fullDataLoaded = false;
this.loadData();
}
},
loadData() {
if (this.query.length < 2) return;
if (this.loading && this.page) return;
this.loading = true;
axios.get(this.searchUrl, {
params: {
q: this.query,
sort: this.sort,
filters: this.filters,
group: this.group,
preview: !this.selected,
page: this.page
}
})
.then((response) => {
if (this.selected) this.fullDataLoaded = true;
this.results = this.results.concat(response.data.data);
this.total = response.data.meta.total;
this.disabled = response.data.meta.disabled;
this.loading = false;
this.page = response.data.meta.next_page;
})
.finally(() => {
this.loading = false;
this.$emit('updated');
});
}
}
};

View file

@ -0,0 +1,26 @@
<template>
<div class="flex flex-col">
<div v-for="_count in loaderRows"
class="flex items-center no-wrap border-0 gap-2 py-2 border-b border-solid border-sn-light-grey gap-x-8"
>
<div class="w-[500px] grow-1 h-6">
<div class="h-full w-80 animate-skeleton rounded mr-auto"></div>
</div>
<div class="w-24 max-w-24 animate-skeleton rounded h-6"></div>
<div class="w-44 max-w-44 animate-skeleton rounded h-6"></div>
<div class="w-44 max-w-44 animate-skeleton rounded h-6"></div>
<div class="w-56 max-w-56 animate-skeleton rounded h-6"></div>
<div class="w-96 max-w-96 animate-skeleton rounded h-6"></div>
</div>
</div>
</template>
<script>
export default {
name: 'Loader',
props: {
loaderRows: { type: Number, default: 0 },
}
};
</script>

View file

@ -37,7 +37,7 @@
<label class="sci-checkbox-label"></label>
</div>
<div v-if="!tag.editing" @click="startEditMode(tag)"
class="h-6 px-1.5 flex items-center max-w-80 truncate text-sn-white rounded"
class="h-6 px-1.5 flex items-center max-w-80 truncate text-sn-white cursor-text rounded"
:class="{
'cursor-pointer': canManage
}"
@ -64,15 +64,12 @@
</template>
</GeneralDropdown>
<input type="text" :value="tag.attributes.name" class="border-0 grow focus:outline-none bg-transparent" @change="updateTagName($event.target.value, tag)"/>
<i @click.stop="finishEditMode($event, tag)" class="sn-icon sn-icon-check cursor-pointer ml-auto"></i>
<i @click.stop="finishEditMode($event, tag)" class="sn-icon sn-icon-check cursor-pointer ml-auto opacity-50 hover:opacity-100" ></i>
</template>
<i v-if="canManage" @click.stop="deleteTag(tag)"
class="tw-hidden sn-icon sn-icon-delete cursor-pointer group-hover:block"
:class="{
'ml-auto': !tag.editing,
'!block': tag.editing
}"
></i>
<i v-if="canManage && !tag.editing" @click="startEditMode(tag)"
class="sn-icon sn-icon-edit cursor-pointer ml-auto tw-hidden group-hover:block opacity-50 hover:opacity-100" ></i>
<i v-if="canManage" @click.stop="deleteTag(tag)"
class="sn-icon sn-icon-delete cursor-pointer tw-hidden group-hover:block opacity-50 hover:opacity-100"></i>
</div>
</template>
</div>
@ -80,13 +77,20 @@
<div class="mb-4 mt-4">
{{ i18n.t('experiments.canvas.modal_manage_tags.create_new') }}
</div>
<div class="flex gap-2">
<div @click="startCreating"
v-click-outside="cancelCreating"
class="flex gap-2 cursor-pointer hover:bg-sn-super-light-grey
rounded px-3 py-2.5 group"
:class="{
'!bg-sn-super-light-blue': creatingTag
}"
>
<GeneralDropdown>
<template v-slot:field>
<div
class="h-6 w-6 border border-solid border-transparent rounded relative flex items-center justify-center text-sn-white"
:style="{ backgroundColor: newTag.color }"
:class="{'!border-sn-grey !text-sn-grey': !newTag.color}"
:class="{'!border-sn-grey !text-sn-grey bg-sn-white': !newTag.color}"
>
a
</div>
@ -104,11 +108,13 @@
</template>
</GeneralDropdown>
<input type="text" v-model="newTag.name"
ref="newTagNameInput"
:placeholder="i18n.t('experiments.canvas.modal_manage_tags.new_tag_name')"
class="border-0 focus:outline-none bg-transparent" />
<i v-if="!creatingTag" class="sn-icon sn-icon-edit opacity-0 group-hover:opacity-50 ml-auto"></i>
<i v-if="validNewTag" @click.stop="createTag" class="sn-icon sn-icon-check cursor-pointer ml-auto"></i>
<i @click.stop="newTag = { name: null, color: null }"
class="tw-hidden sn-icon sn-icon-delete cursor-pointer "
<i @click.stop="cancelCreating"
class="tw-hidden sn-icon sn-icon-close cursor-pointer "
:class="{
'ml-auto': !validNewTag,
'!block': newTag.name || newTag.color
@ -175,6 +181,7 @@ export default {
},
loadingTags: false,
tagToUpdate: null,
creatingTag: false,
query: ''
};
},
@ -198,6 +205,7 @@ export default {
this.finishEditMode();
tag.initialName = tag.attributes.name;
tag.editing = true;
this.tagToUpdate = tag;
this.$nextTick(() => {
@ -210,6 +218,9 @@ export default {
const tagToFinish = tag || this.allTags.find((t) => t.editing);
if (tagToFinish) {
if (this.tagToUpdate.attributes.name.length === 0) {
this.tagToUpdate.attributes.name = this.tagToUpdate.initialName;
}
tagToFinish.editing = false;
this.updateTag(this.tagToUpdate);
}
@ -283,6 +294,7 @@ export default {
}).then(() => {
this.newTag = { name: null, color: null };
this.loadAlltags();
this.creatingTag = false;
});
},
async deleteTag(tag) {
@ -299,6 +311,18 @@ export default {
} else {
document.body.style.overflow = 'hidden';
}
},
startCreating() {
this.creatingTag = true;
this.$nextTick(() => {
this.$refs.newTagNameInput.focus();
});
},
cancelCreating(e) {
if (e && e.target.closest('.sn-dropdown')) return;
this.creatingTag = false;
this.newTag = { name: null, color: null };
}
}
};

View file

@ -2,6 +2,7 @@
<div class="flex relative items-center gap-2">
<DateTimePicker
v-if="this.params.data.urls.update_due_date"
class="borderless-input -mt-[1px]"
:defaultValue="dueDate"
@change="updateDueDate"
mode="datetime"

View file

@ -1,15 +1,16 @@
<template>
<div class="flex items-center gap-1.5 h-9 mt-0.5">
<template v-if="params.data.tags.length > 0 || params.data.permissions.manage_tags">
<div v-if="params.data.tags.length > 0"
class="h-6 px-1.5 flex items-center rounded text-white max-w-[150px]"
:style="{'background': params.data.tags[0].color}">
<div class="truncate">{{ params.data.tags[0].name }}</div>
</div>
<GeneralDropdown v-if="params.data.tags.length > 1" >
<GeneralDropdown v-if="params.data.tags.length > 0">
<template v-slot:field>
<div class="h-6 min-w-[24px] text-sn-dark-grey flex items-center justify-center rounded-full text-[.625rem]
bg-sn-light-grey border !border-sn-sleepy-grey cursor-pointer">
<div
class="h-6 px-1.5 inline-flex items-center rounded text-white max-w-[150px]"
:style="{'background': params.data.tags[0].color}">
<div class="truncate">{{ params.data.tags[0].name }}</div>
</div>
<div v-if="params.data.tags.length > 1"
class="h-6 min-w-[24px] text-sn-dark-grey inline-flex items-center justify-center rounded-full text-[.625rem]
ml-1.5 bg-sn-light-grey border !border-sn-sleepy-grey cursor-pointer">
<span>+{{ params.data.tags.length - 1 }}</span>
</div>
</template>

View file

@ -0,0 +1,332 @@
<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"
:class="{'active': flyoutOpened}"
@focus="openHistory" :placeholder="i18n.t('nav.search')" @keyup.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>
</div>
</div>
</template>
<template v-slot:flyout >
<SearchFilters
class="px-3.5"
v-if="filtersOpened"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
:currentTeam="currentTeam"
:searchUrl="searchUrl"
:searchQuery="searchQuery"
@cancel="filtersOpened = false"
></SearchFilters>
<div v-else-if="showHistory" class="max-w-[600px]">
<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">
<i class="sn-icon sn-icon-history-search"></i>
{{ query }}
</div>
</div>
<div v-else class="w-[600px]">
<div class="flex items-center gap-2">
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'experiments'}"
@click="setQuickFilter('experiments')">
{{ i18n.t('search.quick_search.experiments') }}
</button>
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'my_modules'}"
@click="setQuickFilter('my_modules')">
{{ i18n.t('search.quick_search.tasks') }}
</button>
<button class="btn btn-secondary btn-xs"
:class="{'active': quickFilter === 'results'}"
@click="setQuickFilter('results')">
{{ 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
text-sn-black hover:no-underline active:no-underline hover:text-black block"
>
<div class="flex items-center gap-2">
<i class="sn-icon shrink-0" :class="getIcon(result.type)" :title="getTitle(result.type)"></i>
<span v-if="result.attributes.archived">(A)</span>
<StringWithEllipsis class="grow max-w-[400px]" :text="getName(result.attributes)"></StringWithEllipsis>
<div class="ml-auto pl-4 text-sn-grey text-xs shrink-0">
{{ result.attributes.updated_at }}
</div>
</div>
<div
class="text-sn-grey text-xs flex items-center gap-1 pl-8"
:class="{'opacity-0': result.type.includes('label_templates')}"
>
<div v-for="(breadcrumb, i) in getBreadcrumb(result.attributes)" :key="i"
class="flex items-center gap-1"
>
<span v="if" v-if="i !== 0">/</span>
<span :title="breadcrumb" class="truncate max-w-[130px]">{{ breadcrumb }}</span>
</div>
</div>
</a>
<div v-else v-for="i in Array(5).fill(5)" class="px-3 py-2">
<div class="flex items-center gap-2 mb-2">
<div class="h-5 w-5 bg-sn-light-grey rounded shrink-0"></div>
<div class="h-5 grow max-w-[200px] bg-sn-light-grey rounded shrink-0"></div>
<div class="h-5 w-12 bg-sn-light-grey rounded shrink-0 ml-auto"></div>
</div>
<div class="flex items-center gap-2 pl-8">
<div class="h-3 grow max-w-[200px] bg-sn-light-grey rounded shrink-0"></div>
</div>
</div>
<div v-if="!loading && results.length === 0" class="p-2 flex items-center gap-6">
<i class="sn-icon sn-icon-search text-sn-sleepy-grey" style="font-size: 64px !important;"></i>
<div>
<b>{{ i18n.t('search.quick_search.empty_title', {team: currentTeamName}) }}</b>
<div class="text-xs text-sn-dark-grey">
{{ i18n.t('search.quick_search.empty_description', {query: searchQuery}) }}
</div>
</div>
</div>
<hr class="my-2">
<div class="btn btn-light" @click="searchValue">
{{ i18n.t('search.quick_search.all_results', {query: searchQuery}) }}
</div>
</div>
</template>
</GeneralDropdown>
</template>
<script>
import GeneralDropdown from '../shared/general_dropdown.vue';
import StringWithEllipsis from '../shared/string_with_ellipsis.vue';
import SearchFilters from '../global_search/filters.vue';
import axios from '../../packs/custom_axios.js';
export default {
name: 'QuickSearch',
props: {
quickSearchUrl: {
type: String,
required: true
},
currentTeam: {
type: Number
},
searchUrl: {
type: String,
required: true
},
teamsUrl: {
type: String,
required: true
},
usersUrl: {
type: String,
required: true
}
},
components: {
GeneralDropdown,
StringWithEllipsis,
SearchFilters
},
computed: {
reversedPreviousQueries() {
return [...this.previousQueries].reverse();
},
canOpen() {
return this.previousQueries.length > 0 || this.searchQuery.length > 1;
},
showHistory() {
return this.searchQuery.length < 2;
},
currentTeamName() {
return document.querySelector('body').dataset.currentTeamName;
}
},
watch: {
searchQuery() {
this.openHistory();
if (this.searchQuery.length > 1) {
this.fetchQuickSearchResults();
}
}
},
data() {
return {
searchQuery: '',
previousQueries: [],
quickFilter: null,
results: [],
loading: false,
filtersOpened: false,
focusedHistoryItem: null,
flyoutOpened: false
};
},
mounted() {
this.previousQueries = JSON.parse(localStorage.getItem('quickSearchHistory') || '[]');
},
methods: {
openHistory() {
this.$refs.container.isOpen = this.canOpen;
},
getIcon(type) {
switch (type) {
case 'projects':
return 'sn-icon-projects';
case 'experiments':
return 'sn-icon-experiment';
case 'my_modules':
return 'sn-icon-task';
case 'project_folders':
return 'sn-icon-folder';
case 'protocols':
return 'sn-icon-protocols-templates';
case 'results':
return 'sn-icon-results';
case 'repository_rows':
return 'sn-icon-inventory';
case 'reports':
return 'sn-icon-reports';
case 'steps':
return 'sn-icon-steps';
case 'zebra_label_templates':
return 'sn-icon-label-templates';
case 'fluics_label_templates':
return 'sn-icon-label-templates';
default:
return null;
}
},
getTitle(type) {
switch (type) {
case 'projects':
return this.i18n.t('search.quick_search.project');
case 'experiments':
return this.i18n.t('search.quick_search.experiment');
case 'my_modules':
return this.i18n.t('search.quick_search.task');
case 'project_folders':
return this.i18n.t('search.quick_search.folder');
case 'protocols':
return this.i18n.t('search.quick_search.protocol');
case 'results':
return this.i18n.t('search.quick_search.result');
case 'repository_rows':
return this.i18n.t('search.quick_search.inventory_item');
case 'reports':
return this.i18n.t('search.quick_search.report');
case 'steps':
return this.i18n.t('search.quick_search.step');
case 'zebra_label_templates':
return this.i18n.t('search.quick_search.label_template');
case 'fluics_label_templates':
return this.i18n.t('search.quick_search.label_template');
default:
return null;
}
},
getName(attributes) {
return attributes.breadcrumbs[attributes.breadcrumbs.length - 1].name;
},
getUrl(attributes) {
return attributes.breadcrumbs[attributes.breadcrumbs.length - 1].url;
},
getBreadcrumb(attributes) {
const breadcrumbs = attributes.breadcrumbs.map((breadcrumb) => breadcrumb.name);
breadcrumbs.pop();
breadcrumbs.shift();
if (attributes.code) {
breadcrumbs.push(`ID: ${attributes.code}`);
}
return breadcrumbs;
},
setQuery(query) {
this.searchQuery = query;
this.$nextTick(() => {
this.$refs.searchField.focus();
});
},
saveQuery() {
if (this.searchQuery.length > 1) {
this.previousQueries.push(this.searchQuery);
if (this.previousQueries.length > 5) {
this.previousQueries = this.previousQueries.slice(1);
}
localStorage.setItem('quickSearchHistory', JSON.stringify(this.previousQueries));
this.searchValue();
}
},
setQuickFilter(filter) {
this.quickFilter = this.quickFilter === filter ? null : filter;
this.fetchQuickSearchResults();
},
fetchQuickSearchResults() {
if (this.loading) return;
this.loading = true;
const params = {
query: this.searchQuery,
filter: this.quickFilter
};
axios.get(this.quickSearchUrl, { params })
.then((response) => {
this.results = response.data.data;
this.loading = false;
if (params.query !== this.searchQuery) {
this.fetchQuickSearchResults();
}
})
.catch(() => {
this.results = [];
this.loading = false;
});
},
searchValue() {
window.open(`${this.searchUrl}?q=${this.searchQuery}&teams[]=${this.currentTeam}`, '_self');
},
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();
}
}
}
};
</script>

View file

@ -1,16 +1,21 @@
<template>
<div class="sci--navigation--top-menu-container">
<div v-if="user" class="sci--navigation--top-menu-search left-icon sci-input-container-v2" :class="{'disabled' : !currentTeam}" :title="i18n.t('nav.search')">
<input type="text" :placeholder="i18n.t('nav.search')" @change="searchValue"/>
<i class="sn-icon sn-icon-search"></i>
</div>
<div v-if="currentTeam" class="w-64">
<div v-if="currentTeam" class="w-64" :data-e2e="'e2e-DD-topMenu-teams'">
<SelectDropdown
:value="currentTeam"
:options="teams"
@change="switchTeam"
></SelectDropdown>
</div>
<QuickSearch
v-if="user"
:class="{'hidden': hideSearch}"
:quickSearchUrl="quickSearchUrl"
:searchUrl="searchUrl"
:currentTeam="currentTeam"
:teamsUrl="teamsUrl"
:usersUrl="usersUrl"
></QuickSearch>
<MenuDropdown
class="ml-auto"
v-if="settingsMenu && settingsMenu.length > 0"
@ -71,6 +76,7 @@ import DropdownSelector from '../shared/legacy/dropdown_selector.vue';
import SelectDropdown from '../shared/select_dropdown.vue';
import MenuDropdown from '../shared/menu_dropdown.vue';
import GeneralDropdown from '../shared/general_dropdown.vue';
import QuickSearch from './quick_search.vue';
export default {
name: 'TopMenuContainer',
@ -79,12 +85,16 @@ export default {
NotificationsFlyout,
SelectDropdown,
MenuDropdown,
GeneralDropdown
GeneralDropdown,
QuickSearch
},
props: {
url: String,
notificationsUrl: String,
unseenNotificationsUrl: String
unseenNotificationsUrl: String,
quickSearchUrl: String,
teamsUrl: String,
usersUrl: String
},
data() {
return {
@ -97,7 +107,8 @@ export default {
helpMenu: null,
settingsMenu: null,
userMenu: null,
unseenNotificationsCount: 0
unseenNotificationsCount: 0,
hideSearch: false,
};
},
created() {
@ -108,11 +119,15 @@ export default {
this.notificationsOpened = false;
this.checkUnseenNotifications();
this.refreshCurrentTeam();
this.hideSearch = !!document.getElementById('GlobalSearch');
});
// Track name update in user profile settings
$(document).on('inlineEditing::updated', '.inline-editing-container[data-field-to-update="full_name"]', this.fetchData);
},
mounted() {
this.hideSearch = !!document.getElementById('GlobalSearch');
},
beforeUnmount() {
clearTimeout(this.unseenNotificationsTimeout);
},

View file

@ -1,7 +1,7 @@
<template>
<div v-if="!params.folder"
:class="{ 'bg-sn-light-grey': dtComponent.currentViewMode === 'archived', [cardMinWidth]: true }"
class="px-3 pt-3 pb-4 rounded border-solid border border-sn-gray flex flex-col" >
:class="{ 'bg-sn-grey-100': dtComponent.currentViewMode === 'archived', [cardMinWidth]: true }"
class="px-3 pt-3 pb-4 rounded border-solid border border-sn-gray-300 flex flex-col" >
<div class="flex items-center gap-4 mb-2">
<div class="sci-checkbox-container">
<input
@ -17,7 +17,10 @@
</div>
<a :href="params.urls.show"
:title="params.name"
:class="{'pointer-events-none text-sn-grey': !params.urls.show}"
:class="{
'pointer-events-none !text-sn-grey': !params.urls.show,
'!text-sn-black': dtComponent.currentViewMode === 'archived',
}"
class="font-bold mb-4 text-sn-blue shrink-0 hover:no-underline line-clamp-3 hover:text-sn-blue h-[60px]">
{{ params.name }}
</a>
@ -40,7 +43,7 @@
<div v-else
class="px-3 pt-3 pb-4 rounded border-solid border border-sn-gray flex flex-col h-56"
:class="{
'bg-sn-light-grey': dtComponent.currentViewMode === 'archived',
'bg-sn-grey-100': dtComponent.currentViewMode === 'archived',
'bg-sn-super-light-grey': dtComponent.currentViewMode !== 'archived',
[cardMinWidth]: true
}"
@ -58,15 +61,17 @@
<RowMenuRenderer :params="{data: params, dtComponent: dtComponent}" class="ml-auto"/>
</div>
<div
class="flex flex-col items-center justify-center"
class="flex flex-col items-center justify-center pt-4"
:class="{
'text-sn-black hover:text-sn-black': dtComponent.currentViewMode === 'archived',
'text-sn-blue hover:text-sn-blue': dtComponent.currentViewMode !== 'archived'
}"
>
<i class="sn-icon sn-icon-folder " style="font-size: 56px !important"></i>
<i class="sn-icon sn-icon-folder mb-2" style="font-size: 56px !important"></i>
<a :href="params.urls.show"
class="line-clamp-2 font-bold mb-2 text-inherit text-center hover:no-underline ">
class="line-clamp-2 font-bold mb-2 text-inherit text-center hover:no-underline "
:class="{'!text-sn-black': dtComponent.currentViewMode === 'archived'}"
>
{{ params.name }}
</a>
<div class="flex items-center justify-center text-sn-dark-grey">

View file

@ -8,7 +8,7 @@
+{{ hiddenUsers.length }}
</div>
<div v-if="params.data.permissions['manage_users_assignments']"
class="flex items-center shrink-0 justify-center w-7 h-7 rounded-full bg-sn-light-grey text-sn-dark-grey">
class="flex items-center shrink-0 justify-center w-7 h-7 rounded-full border-dashed bg-sn-white text-sn-sleepy-grey border-sn-sleepy-grey">
<i class="sn-icon sn-icon-new-task"></i>
</div>
</div>

View file

@ -25,7 +25,7 @@
@click="$emit('publish')" class="btn btn-primary">
{{ i18n.t("protocols.header.publish") }}</button>
<button v-if="protocol.attributes.urls.save_as_draft_url"
v-bind:disabled="protocol.attributes.has_draft"
:disabled="protocol.attributes.has_draft || creatingDraft"
@click="saveAsdraft" class="btn btn-secondary">
{{ i18n.t("protocols.header.save_as_draft") }}
</button>
@ -121,7 +121,8 @@ export default {
},
data() {
return {
VersionsModalObject: null
VersionsModalObject: null,
creatingDraft: false
};
},
computed: {
@ -141,8 +142,18 @@ export default {
},
methods: {
saveAsdraft() {
if (this.creatingDraft) {
return;
}
this.creatingDraft = true;
$.post(this.protocol.attributes.urls.save_as_draft_url, (result) => {
this.creatingDraft = false;
window.location.replace(result.url);
}).error(() => {
this.creatingDraft = false;
HelperModule.flashAlertMsg(this.i18n.t('errors.general'));
});
},
updateAuthors(authors) {

View file

@ -5,7 +5,7 @@
@dragenter.prevent="dragEnter($event)"
@dragover.prevent
:data-id="step.id"
:class="{ 'draging-file': dragingFile, 'editing-name': editingName, 'locked': !urls.update_url }"
:class="{ 'draging-file': dragingFile, 'editing-name': editingName, 'locked': !urls.update_url, 'pointer-events-none': addingContent }"
>
<div class="drop-message" @dragleave.prevent="!showFileModal ? dragingFile = false : null">
{{ i18n.t('protocols.steps.drop_message', { position: step.attributes.position + 1 }) }}
@ -116,6 +116,7 @@
:reorderElementUrl="elements.length > 1 ? urls.reorder_elements_url : ''"
:assignableMyModuleId="assignableMyModuleId"
:isNew="element.isNew"
@component:adding-content="($event) => addingContent = $event"
@component:delete="deleteElement"
@update="updateElement"
@reorder="openReorderModal"
@ -205,6 +206,7 @@
attachments: [],
attachmentsReady: false,
confirmingDelete: false,
addingContent: false,
showFileModal: false,
showCommentsSidebar: false,
dragingFile: false,

View file

@ -51,7 +51,7 @@ import VersionsRenderer from './renderers/versions.vue';
import VersionsModal from './modals/versions.vue';
export default {
name: 'LabelTemplatesTable',
name: 'ProtocolTemplatesTable',
components: {
DataTable,
UsersRenderer,

View file

@ -2,6 +2,7 @@
<div class="h-full">
<DataTable :columnDefs="columnDefs"
:tableId="'ReportTemplates'"
ref="table"
:dataUrl="dataSource"
:reloadingTable="reloadingTable"
:toolbarActions="toolbarActions"
@ -48,6 +49,13 @@ import UpdateReportModal from './modals/update.vue';
export default {
name: 'ReportsTable',
mounted() {
this.$nextTick(() => {
if (this.searchValue) {
this.$refs.table.searchValue = this.searchValue;
}
});
},
components: {
DataTable,
DocxRenderer,
@ -61,6 +69,10 @@ export default {
type: String,
required: true
},
searchValue: {
type: String,
default: ''
},
actionsUrl: {
type: String,
required: true

View file

@ -2,28 +2,29 @@
<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-duplicateInventory">
<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-duplicateInventoryModal-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" data-e2e="e2e-TX-duplicateInventoryModal-title" :title="repository.name">
{{ i18n.t('repositories.index.modal_copy.title_html', {name: repository.name }) }}
</h4>
</div>
<div class="modal-body">
<div class="mb-6">
<label class="sci-label">{{ i18n.t("repositories.index.modal_copy.name") }}</label>
<label class="sci-label" data-e2e="e2e-TX-duplicateInventoryModal-inputLabel">{{ i18n.t("repositories.index.modal_copy.name") }}</label>
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name" class="sci-input-field"
autofocus="true" ref="input"
data-e2e="e2e-IF-duplicateInventoryModal-name"
:placeholder="i18n.t('repositories.index.modal_copy.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-duplicateInventoryModal-cancel">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" type="submit" data-e2e="e2e-BT-duplicateInventoryModal-create">
{{ i18n.t('repositories.index.modal_copy.copy') }}
</button>
</div>

View file

@ -2,29 +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-newInventory">
<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-newInventoryModal-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-newInventoryModal-title">
{{ i18n.t('repositories.index.modal_create.title') }}
</h4>
</div>
<div class="modal-body">
<div class="mb-6">
<label class="sci-label">{{ i18n.t("repositories.index.modal_create.name_label") }}</label>
<label class="sci-label" data-e2e="e2e-TX-newInventoryModal-inputLabel">{{ i18n.t("repositories.index.modal_create.name_label") }}</label>
<div class="sci-input-container-v2" :class="{'error': error}" :data-error="error">
<input type="text" v-model="name"
class="sci-input-field"
autofocus="true" ref="input"
data-e2e="e2e-IF-newInventoryModal-name"
:placeholder="i18n.t('repositories.index.modal_create.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-newInventoryModal-cancel">{{ i18n.t('general.cancel') }}</button>
<button class="btn btn-primary" type="submit" data-e2e="e2e-BT-newInventoryModal-create">
{{ i18n.t('repositories.index.modal_create.submit') }}
</button>
</div>

View file

@ -175,7 +175,7 @@ export default {
},
toolbarActions() {
const left = [];
if (this.createUrl) {
if (this.createUrl && this.currentViewMode !== 'archived') {
left.push({
name: 'create',
icon: 'sn-icon sn-icon-new-task',

View file

@ -1,7 +1,7 @@
<template>
<MenuDropdown
:listItems="viewModesMenu"
:btnClasses="'btn btn-secondary !border-sn-light-grey px-3'"
:btnClasses="'btn btn-secondary !border-sn-light-grey px-3 prevent-shrink' + disabled"
:btnText="btnText"
:caret="true"
position='right'>
@ -20,12 +20,16 @@ export default {
props: {
viewMode: { type: String, required: true },
activeUrl: { type: String, required: true },
archivedUrl: { type: String, required: true }
archivedUrl: { type: String, required: true },
disabled: { type: String, default: 'false' }
},
beforeDestroy() {
delete window.initRepositoryStateMenu;
},
computed: {
disabled() {
return this.disabled === 'true' ? ' disabled' : '';
},
btnText() {
return I18n.t(`toolbar.${this.viewMode}_state`);
},

View file

@ -0,0 +1,51 @@
<template>
<transition enter-from-class="translate-x-full w-0"
enter-active-class="transition-all ease-sharp duration-[588ms]"
leave-active-class="transition-all ease-sharp duration-[588ms]"
leave-to-class="translate-x-full w-0"
v-click-outside="close">
<div ref="wrapper" v-if="isShowing"
class='items-sidebar-wrapper bg-white gap-2.5 self-stretch rounded-tl-4 rounded-bl-4 sn-shadow-menu-lg h-full w-[565px]'>
<div class="w-full h-full pl-6 bg-white flex flex-col">
<div class="sticky top-0 right-0 bg-white flex z-50 flex-col h-[78px] pt-6">
<div class="header flex w-full h-[30px] pr-6">
<i id="close-icon" @click="close"
class="sn-icon sn-icon-close ml-auto cursor-pointer my-auto mx-0"></i>
</div>
<div id="divider" class="bg-sn-light-grey flex items-center self-stretch h-px mt-6 mr-6"></div>
</div>
<div class="flex flex-col flex-1 justify-center items-center gap-1">
<i class="sn-icon sn-icon-alert-warning text-sn-alert-passion"></i>
<h4 class="font-semibold text-lg">{{ i18n.t('repositories.item_card_errors.item_not_found') }}</h4>
</div>
</div>
</div>
</transition>
</template>
<script>
import { vOnClickOutside } from '@vueuse/components';
export default {
name: 'RepositoryItemErrorSidebar',
directives: {
'click-outside': vOnClickOutside
},
data() {
return {
isShowing: false
};
},
mounted() {
this.isShowing = true;
},
methods: {
close() {
this.$nextTick(() => {
this.isShowing = false;
});
}
}
};
</script>

View file

@ -7,7 +7,7 @@
<div ref="wrapper" v-show="isShowing" id="repository-item-sidebar-wrapper"
class='items-sidebar-wrapper bg-white gap-2.5 self-stretch rounded-tl-4 rounded-bl-4 sn-shadow-menu-lg h-full w-[565px]'>
<div id="repository-item-sidebar" class="w-full h-full pl-6 bg-white flex flex-col">
<div id="repository-item-sidebar" data-e2e="e2e-CO-itemCard" class="w-full h-full pl-6 bg-white flex flex-col">
<div ref="stickyHeaderRef" id="sticky-header-wrapper"
class="sticky top-0 right-0 bg-white flex z-50 flex-col h-[78px] pt-6">
@ -17,12 +17,12 @@
:name="defaultColumns.name"
:archived="defaultColumns.archived"
@update="update"
data-e2e="e2e-TX-repoItemSB-title">
data-e2e="e2e-TX-itemCard-title">
</repository-item-sidebar-title>
<i id="close-icon" @click="toggleShowHideSidebar(null)"
<i id="close-icon" data-e2e="e2e-BT-itemCard-close" @click="toggleShowHideSidebar(null)"
class="sn-icon sn-icon-close ml-auto cursor-pointer my-auto mx-0"></i>
</div>
<div id="divider" class="w-500 bg-sn-light-grey flex items-center self-stretch h-px mt-6 mr-6"></div>
<div id="divider" class="bg-sn-light-grey flex items-center self-stretch h-px mt-6 mr-6"></div>
</div>
<div ref="bodyWrapper" id="body-wrapper" class="overflow-y-auto overflow-x-hidden h-[calc(100%-78px)] pt-6 ">
@ -56,7 +56,7 @@
<div class="flex flex-col ">
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.repository_name') }}</span>
<span class="repository-name text-sn-dark-grey line-clamp-3" :title="repository?.name" data-e2e="e2e-TX-repoItemSBinformation-inventory">
<span class="repository-name text-sn-dark-grey line-clamp-3" :title="repository?.name" data-e2e="e2e-TX-itemCard-inventory">
{{ repository?.name }}
</span>
</div>
@ -68,7 +68,7 @@
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.id')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.code" data-e2e="e2e-TX-repoItemSBinformation-itemID">
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.code" data-e2e="e2e-TX-itemCard-itemID">
{{ defaultColumns?.code }}
</span>
</div>
@ -80,7 +80,7 @@
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.added_on')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns?.added_on" data-e2e="e2e-TX-repoItemSBinformation-addedOn">
<span class="inline-block text-sn-dark-grey" :title="defaultColumns?.added_on" data-e2e="e2e-TX-itemCard-addedOn">
{{ defaultColumns?.added_on }}
</span>
</div>
@ -92,7 +92,7 @@
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.added_by')
}}</span>
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.added_by" data-e2e="e2e-TX-repoItemSBinformation-addedBy">
<span class="inline-block text-sn-dark-grey line-clamp-3" :title="defaultColumns?.added_by" data-e2e="e2e-TX-itemCard-addedBy">
{{ defaultColumns?.added_by }}
</span>
</div>
@ -103,7 +103,7 @@
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.archived_on')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_on" data-e2e="e2e-TX-repoItemSBinformation-archivedOn">
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_on" data-e2e="e2e-TX-itemCard-archivedOn">
{{ defaultColumns.archived_on }}
</span>
</div>
@ -114,7 +114,7 @@
<span class="inline-block font-semibold pb-[6px]">{{
i18n.t('repositories.item_card.default_columns.archived_by')
}}</span>
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_by.full_name" data-e2e="e2e-TX-repoItemSBinformation-archivedBy">
<span class="inline-block text-sn-dark-grey" :title="defaultColumns.archived_by.full_name" data-e2e="e2e-TX-itemCard-archivedBy">
{{ defaultColumns.archived_by.full_name }}
</span>
</div>
@ -122,7 +122,7 @@
</div>
</section>
<div id="divider" class="w-500 bg-sn-light-grey flex items-center self-stretch h-px "></div>
<div id="divider" class="bg-sn-light-grey flex items-center self-stretch h-px "></div>
<!-- CUSTOM COLUMNS, RELATIONSHIPS, ASSIGNED, QR CODE -->
<div id="custom-col-assigned-qr-wrapper" class="flex flex-col gap-6">
@ -138,7 +138,7 @@
:permissions="permissions" :updatePath="updatePath" :actions="actions" @update="update" />
</section>
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px"></div>
<div id="divider" class="bg-sn-light-grey flex px-8 items-center self-stretch h-px"></div>
<!-- RELATIONSHIPS -->
<section v-if="!repository?.is_snapshot" id="relationships-section" class="flex flex-col" ref="relationshipsSectionRef">
@ -148,14 +148,14 @@
</div>
<div class="font-inter text-sm leading-5 w-full">
<div class="flex flex-row justify-between mb-4">
<div class="font-semibold" data-e2e="e2e-TX-repoItemSBrelationships-parents">
<div class="font-semibold" data-e2e="e2e-TX-itemCard-parents">
{{ i18n.t('repositories.item_card.relationships.parents.count', { count: parentsCount || 0 }) }}
</div>
<a
v-if="permissions.can_connect_rows"
class="relationships-add-link btn-text-link font-normal"
@click="handleOpenAddRelationshipsModal($event, 'parent')"
data-e2e="e2e-TL-repoItemSBrelationships-addParents"
data-e2e="e2e-BT-itemCard-addParent"
>
{{ i18n.t('repositories.item_card.add_relationship_button_text') }}
</a>
@ -196,14 +196,14 @@
<div class="font-inter text-sm leading-5 w-full">
<div class="flex flex-row justify-between" :class="{ 'mb-4': childrenCount }">
<div class="font-semibold" data-e2e="e2e-TX-repoItemSBrelationships-children">
<div class="font-semibold" data-e2e="e2e-TX-itemCard-children">
{{ i18n.t('repositories.item_card.relationships.children.count', { count: childrenCount || 0 }) }}
</div>
<a
v-if="permissions.can_connect_rows"
class="relationships-add-link btn-text-link font-normal"
@click="handleOpenAddRelationshipsModal($event, 'child')"
data-e2e="e2e-TL-repoItemSBrelationships-addChildren"
data-e2e="e2e-BT-itemCard-addChild"
>
{{ i18n.t('repositories.item_card.add_relationship_button_text') }}
</a>
@ -241,7 +241,7 @@
</div>
</section>
<div v-if="!repository?.is_snapshot" id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px"></div>
<div v-if="!repository?.is_snapshot" id="divider" class="bg-sn-light-grey flex px-8 items-center self-stretch h-px"></div>
<!-- ASSIGNED -->
<section v-if="!repository?.is_snapshot" id="assigned-section" class="flex flex-col" ref="assignedSectionRef">
@ -249,7 +249,7 @@
class="flex flex-row text-lg font-semibold w-[350px] mb-6 leading-7 items-center justify-between transition-colors duration-300"
ref="assigned-label"
id="assigned-label"
data-e2e="e2e-TX-repoItemSB-assigned"
data-e2e="e2e-TX-itemCard-assigned"
>
{{ i18n.t('repositories.item_card.section.assigned', {
count: assignedModules ?
@ -261,7 +261,7 @@
'disabled': actions?.assign_repository_row && actions.assign_repository_row.disabled
}"
:data-assign-url="actions?.assign_repository_row ? actions.assign_repository_row.assign_url : ''"
:data-repository-row-id="repositoryRowId" @click="showRepositoryAssignModal" data-e2e="e2e-TL-repoItemSBassigned-assignToTask">
:data-repository-row-id="repositoryRowId" @click="showRepositoryAssignModal" data-e2e="e2e-TL-repoItemSB-assignToTask">
{{ i18n.t('repositories.item_card.assigned.assign') }}
</a>
</div>
@ -292,7 +292,7 @@
</div>
</section>
<div v-if="!repository?.is_snapshot" id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px "></div>
<div v-if="!repository?.is_snapshot" id="divider" class="bg-sn-light-grey flex px-8 items-center self-stretch h-px "></div>
<!-- QR -->
<section id="qr-section" ref="QR-label">
@ -318,9 +318,9 @@
<!-- BOTTOM -->
<div id="bottom" v-show="!dataLoading && !loadingError" class="h-[100px] flex flex-col justify-end mt-4 mr-6"
:class="{ 'pb-6': customColumns?.length }">
<div id="divider" class="w-500 bg-sn-light-grey flex px-8 items-center self-stretch h-px mb-6"></div>
<div id="divider" class="bg-sn-light-grey flex px-8 items-center self-stretch h-px mb-6"></div>
<div id="bottom-button-wrapper" class="flex h-10 justify-end">
<button type="button" class="btn btn-primary print-label-button" data-e2e="e2e-BT-repoItemSB-print"
<button type="button" class="btn btn-primary print-label-button" data-e2e="e2e-BT-itemCard-print"
:data-rows="JSON.stringify([repositoryRowId])"
:data-repository-id="repository?.id">
{{ i18n.t('repositories.item_card.print_label') }}

View file

@ -66,7 +66,7 @@ export default {
mounted() {
this.isLoading = true;
$.get(this.optionsPath, (data) => {
$.get(this.optionsPath, { all_options: true }, (data) => {
if (Array.isArray(data)) {
this.options = data.map((option) => {
const { value, label } = option;
@ -75,6 +75,7 @@ export default {
return false;
}
this.options = [];
return true;
}).always(() => {
this.isLoading = false;
this.selected = this.id;

View file

@ -272,7 +272,7 @@ export default {
templateOption(option) {
return `
<div class="label-template-option" data-toggle="tooltip" data-placement="right" title="${option.params.description}">
${option.params.icon}
<img src="${option.params.icon}"></img>
${option.label}
</div>
`;

View file

@ -5,7 +5,7 @@
@dragenter.prevent="dragEnter($event)"
@dragover.prevent
:data-id="result.id"
:class="{ 'bg-sn-super-light-blue': dragingFile, 'bg-white': !dragingFile, 'locked': locked }"
:class="{ 'bg-sn-super-light-blue': dragingFile, 'bg-white': !dragingFile, 'locked': locked, 'pointer-events-none': addingContent }"
>
<div class="text-xl items-center flex flex-col text-sn-blue h-full justify-center left-0 absolute top-0 w-full"
v-if="dragingFile"
@ -110,6 +110,7 @@
:reorderElementUrl="elements.length > 1 ? urls.reorder_elements_url : ''"
:assignableMyModuleId="result.attributes.my_module_id"
:isNew="element.isNew"
@component:adding-content="($event) => addingContent = $event"
@component:delete="deleteElement"
@update="updateElement"
@reorder="openReorderModal"
@ -165,6 +166,7 @@ export default {
elements: [],
attachments: [],
attachmentsReady: false,
addingContent: false,
showFileModal: false,
dragingFile: false,
wellPlateOptions: [

View file

@ -3,6 +3,7 @@
<button class="ml-2 btn"
id="share-button"
type="button"
data-e2e="e2e-BT-tasks-shareTask"
:class="shareClass"
:title="shareValue"
@click="openModal">

View file

@ -1,44 +1,44 @@
<template>
<div>
<div v-if="roles.length > 0 && visible && default_role" class="p-2 flex items-center gap-2 border-solid border-0 border-b border-b-sn-sleepy-grey">
<div>
<img src="/images/icon/team.png" class="rounded-full w-8 h-8">
</div>
<div>
{{ i18n.t('access_permissions.everyone_else', { team_name: params.object.team }) }}
</div>
<GeneralDropdown @open="loadUsers" @close="closeFlyout">
<template v-slot:field>
<i class="sn-icon sn-icon-info"></i>
</template>
<template v-slot:flyout>
<perfect-scrollbar class="flex flex-col max-h-96 max-w-[280px] relative pr-4 gap-y-px">
<div v-for="user in this.autoAssignedUsers"
:key="user.attributes.user.id"
:title="user.attributes.user.name"
class="rounded px-3 py-2.5 flex items-center hover:no-underline leading-5 gap-2">
<img :src="user.attributes.user.avatar_url" class="w-6 h-6 rounded-full">
<span class="truncate">{{ user.attributes.user.name }}</span>
</div>
</perfect-scrollbar>
</template>
</GeneralDropdown>
<MenuDropdown
v-if="params.object.top_level_assignable && params.object.urls.update_access"
class="ml-auto"
:listItems="rolesFromatted(default_role)"
:btnText="this.roles.find((role) => role[0] == default_role)[1]"
:position="'right'"
:caret="true"
@setRole="(...args) => this.changeDefaultRole(...args)"
@removeRole="() => this.changeDefaultRole()"
></MenuDropdown>
<div class="ml-auto btn btn-light pointer-events-none" v-else>
{{ this.roles.find((role) => role[0] == default_role)[1] }}
<div class="h-6 w-6"></div>
</div>
</div>
<perfect-scrollbar class="h-[50vh] relative">
<div v-if="roles.length > 0 && visible && default_role" class="p-2 flex items-center gap-2 border-solid border-0 border-b border-b-sn-sleepy-grey">
<div>
<img src="/images/icon/team.png" class="rounded-full w-8 h-8">
</div>
<div>
{{ i18n.t('access_permissions.everyone_else', { team_name: params.object.team }) }}
</div>
<GeneralDropdown @open="loadUsers" @close="closeFlyout">
<template v-slot:field>
<i class="sn-icon sn-icon-info"></i>
</template>
<template v-slot:flyout>
<perfect-scrollbar class="flex flex-col max-h-96 max-w-[280px] relative pr-4 gap-y-px">
<div v-for="user in this.autoAssignedUsers"
:key="user.attributes.user.id"
:title="user.attributes.user.name"
class="rounded px-3 py-2.5 flex items-center hover:no-underline leading-5 gap-2">
<img :src="user.attributes.user.avatar_url" class="w-6 h-6 rounded-full">
<span class="truncate">{{ user.attributes.user.name }}</span>
</div>
</perfect-scrollbar>
</template>
</GeneralDropdown>
<MenuDropdown
v-if="params.object.top_level_assignable && params.object.urls.update_access"
class="ml-auto"
:listItems="rolesFromatted(default_role)"
:btnText="this.roles.find((role) => role[0] == default_role)[1]"
:position="'right'"
:caret="true"
@setRole="(...args) => this.changeDefaultRole(...args)"
@removeRole="() => this.changeDefaultRole()"
></MenuDropdown>
<div class="ml-auto btn btn-light pointer-events-none" v-else>
{{ this.roles.find((role) => role[0] == default_role)[1] }}
<div class="h-6 w-6"></div>
</div>
</div>
<div v-for="userAssignment in manuallyAssignedUsers"
:key="userAssignment.id"
class="p-2 flex items-center gap-2">

View file

@ -159,17 +159,53 @@ export default {
});
}
}
if (this.attachment.attributes.asset_type === 'gene_sequence' && this.attachment.attributes.urls.open_vector_editor_edit) {
menu.push({
text: this.i18n.t('open_vector_editor.edit_sequence'),
emit: 'open_ove_editor',
data_e2e: 'e2e-BT-attachmentOptions-openInOve'
});
}
if (this.attachment.attributes.asset_type === 'marvinjs' && this.attachment.attributes.urls.marvin_js_start_edit) {
menu.push({
text: this.i18n.t('assets.file_preview.edit_in_marvinjs'),
emit: 'open_marvinjs_editor',
data_e2e: 'e2e-BT-attachmentOptions-openInMarvin'
});
}
if (this.attachment.attributes.asset_type !== 'marvinjs'
&& this.attachment.attributes.image_editable
&& this.attachment.attributes.urls.start_edit_image) {
menu.push({
text: this.i18n.t('assets.file_preview.edit_in_scinote'),
emit: 'open_scinote_editor',
data_e2e: 'e2e-BT-attachmentOptions-openInImageEditor'
});
}
if (this.canOpenLocally) {
const text = this.localAppName
? this.i18n.t('attachments.open_locally_in', { application: this.localAppName })
: this.i18n.t('attachments.open_locally');
menu.push({
text,
emit: 'open_locally',
data_e2e: 'e2e-BT-attachmentOptions-openLocally'
});
}
if (this.displayInDropdown.includes('download')) {
menu.push({
text: this.i18n.t('Download'),
url: this.attachment.attributes.urls.download,
url_target: '_blank'
url_target: '_blank',
data_e2e: 'e2e-BT-attachmentOptions-download'
});
}
if (this.attachment.attributes.urls.move_targets && this.displayInDropdown.includes('move')) {
if (this.attachment.attributes.urls.move_targets) {
menu.push({
text: this.i18n.t('assets.context_menu.move'),
emit: 'move'
emit: 'move',
data_e2e: 'e2e-BT-attachmentOptions-move'
});
}
if (this.attachment.attributes.urls.duplicate) {
@ -187,7 +223,8 @@ export default {
if (this.attachment.attributes.urls.delete) {
menu.push({
text: this.i18n.t('assets.context_menu.delete'),
emit: 'delete'
emit: 'delete',
data_e2e: 'e2e-BT-attachmentOptions-delete'
});
}
if (this.attachment.attributes.urls.toggle_view_mode) {
@ -197,6 +234,7 @@ export default {
text: this.i18n.t(`assets.context_menu.${viewMode}_html`),
emit: 'viewMode',
params: viewMode,
data_e2e: `e2e-BT-attachmentOptions-${viewMode}`,
dividerBefore: i === 0
});
});

View file

@ -3,11 +3,14 @@
export default {
methods: {
duplicateElement() {
this.$emit('component:adding-content', true);
$.post(this.element.attributes.orderable.urls.duplicate_url, (result) => {
this.$emit('component:insert', result.data);
HelperModule.flashAlertMsg(this.i18n.t('protocols.steps.component_duplicated'), 'success');
}).fail(() => {
HelperModule.flashAlertMsg(this.i18n.t('protocols.steps.component_duplication_failed'), 'danger');
}).always(() => {
this.$emit('component:adding-content', false);
});
}
}

View file

@ -1,12 +1,12 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-content" data-e2e="e2e-MD-manageColumns">
<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-manageColumnsModal-close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block">
<h4 class="modal-title truncate !block" data-e2e="e2e-TX-manageColumnsModal-title">
{{ i18n.t('experiments.table.column_display_modal.title') }}
</h4>
</div>
@ -30,18 +30,18 @@
>
<div v-if="element.field == 'pinnedSeparator'" class="h-[1px] w-full bg-sn-sleepy-grey"></div>
<template v-else>
<div class="opacity-0 group-hover/column:!opacity-100 element-grip cursor-pointer">
<div class="opacity-0 group-hover/column:!opacity-100 element-grip cursor-pointer" :data-e2e="'e2e-BT-manageColumnsModal-'+element.field+'-drag'">
<i class="sn-icon sn-icon-drag"></i>
</div>
<div v-if="element.field === 'name'" class="w-6 h-6"></div>
<template v-else>
<i v-if="columnVisbile(element)" @click="toggleColumn(element, true)"
class="sn-icon sn-icon-visibility-show cursor-pointer"></i>
class="sn-icon sn-icon-visibility-show cursor-pointer" :data-e2e="'e2e-BT-manageColumnsModal-'+element.field+'-hide'"></i>
<i v-else @click="toggleColumn(element, false)"
class="sn-icon sn-icon-visibility-hide cursor-pointer"></i>
class="sn-icon sn-icon-visibility-hide cursor-pointer" :data-e2e="'e2e-BT-manageColumnsModal-'+element.field+'-show'"></i>
</template>
<div class="truncate">{{ element.headerName }}</div>
<div class="ml-auto cursor-pointer">
<div class="truncate" :data-e2e="'e2e-TX-manageColumnsModal-'+element.field+'-columnName'">{{ element.headerName }}</div>
<div class="ml-auto cursor-pointer" :data-e2e="'e2e-BT-manageColumnsModal-'+element.field+'-pin'">
<i v-if="columnPinned(element)" @click="unPinColumn(element)" class="sn-icon sn-icon-pinned"></i>
<i v-else @click="pinColumn(element)" class="sn-icon sn-icon-pin"></i>
</div>
@ -52,7 +52,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary mr-auto" @click="resetToDefault">
<button type="button" class="btn btn-secondary mr-auto" @click="resetToDefault" data-e2e="e2e-BT-manageColumnsModal-resetToDefault">
{{ i18n.t('experiments.table.column_display_modal.reset_to_default') }}
</button>
</div>

View file

@ -3,7 +3,7 @@
<span v-if="!params.data.permissions.create_comments && params.data.comments.count === 0">0</span>
<a v-else
href="#"
class="open-comments-sidebar" tabindex=0 :id="'comment-count-' + params.data.id"
class="open-comments-sidebar relative px-1" tabindex=0 :id="'comment-count-' + params.data.id"
:data-object-type="objectType" :data-object-id="params.data.id">
<template v-if="params.data.comments.count > 0">
{{ params.data.comments.count }}
@ -12,7 +12,9 @@
+
</template>
<span v-if="params.data.comments.count_unseen > 0"
class="unseen-comments inline-flex align-super text-xs rounded-[0.875rem] px-1 bg-sn-science-blue text-sn-white h-4 items-center justify-center">
class="unseen-comments flex align-super text-xs rounded-lg bg-sn-science-blue
text-sn-white h-4 min-w-[1rem] items-center justify-center
absolute -top-1.5 left-[100%] px-1">
{{params.data.comments.count_unseen }}
</span>
</a>

View file

@ -2,7 +2,7 @@
<div>
<MenuDropdown
:listItems="this.formattedList"
btnClasses="bg-transparent w-6 h-6 border-0 p-0 flex"
btnClasses="btn btn-light icon-btn"
:position="'right'"
:alwaysShow="true"
:btnIcon="'sn-icon sn-icon-more-hori'"
@ -41,9 +41,8 @@ export default {
if (item.type === 'link') {
newItem.url = item.path;
}
newItem.data_e2e = `e2e-BT-cardActions-${item.name}`;
newItem.params = item;
return newItem;
});
},

View file

@ -2,17 +2,17 @@ export default {
template: `
<div class="w-full grid items-center group gap-2 grid-cols-[auto_1.5rem]"
:class="{'cursor-pointer': params.enableSorting}"
:data-e2e="'e2e-CO-TableHeader-' + params.column.colId "
:data-e2e="'e2e-CO-tableHeader-' + params.column.colId "
@click="onSortRequested((activeSort == 'asc' ? 'desc' : 'asc'), $event)">
<div v-if="params.html" class="customHeaderLabel truncate" v-html="params.html"></div>
<div v-else class="customHeaderLabel truncate">{{ params.displayName }}</div>
<div v-if="activeSort == 'asc'" class="customSortDownLabel text-sn-black">
<div v-if="activeSort == 'asc'" class="customSortDownLabel text-sn-black" data-e2e="e2e-BT-tableHeader-sortedAsc">
<i class="sn-icon sn-icon-sort-up"></i>
</div>
<div v-if="activeSort == 'desc'" class="customSortUpLabel text-sn-black">
<div v-if="activeSort == 'desc'" class="customSortUpLabel text-sn-black" data-e2e="e2e-BT-tableHeader-sortedDesc">
<i class="sn-icon sn-icon-sort-down"></i>
</div>
<div v-if="activeSort == null && params.enableSorting" class="text-sn-black tw-hidden group-hover:block">
<div v-if="activeSort == null && params.enableSorting" class="text-sn-black tw-hidden group-hover:block" data-e2e="e2e-BT-tableHeader-sortUpDown">
<i class="sn-icon sn-icon-sort"></i>
</div>
</div>

View file

@ -31,6 +31,7 @@
:btnText="i18n.t(`toolbar.${currentViewRender}_view`)"
:caret="true"
:position="'right'"
:data-e2e="'e2e-DD-topToolbar-viewDropdown'"
@setCardsView="$emit('setCardsView')"
@setTableView="$emit('setTableView')"
></MenuDropdown>
@ -41,6 +42,7 @@
:btnText="i18n.t(`toolbar.${currentViewMode}_state`)"
:caret="true"
:position="'right'"
:data-e2e="'e2e-DD-topToolbar-stateDropdown'"
></MenuDropdown>
</div>
</div>
@ -53,7 +55,8 @@
{{ action.label }}
</a>
<div class="sci-input-container-v2"
:class="{'w-48': showSearch, 'w-11': !showSearch}">
:class="{'w-48': showSearch, 'w-11': !showSearch}"
:data-e2e="'e2e-BT-topToolbar-search'">
<input
ref="searchInput"
class="sci-input-field !pr-9"
@ -62,16 +65,18 @@
@blur="hideSearch"
:value="searchValue"
:placeholder="'Search...'"
:data-e2e="'e2e-IF-topToolbar-search'"
@change="$emit('search:change', $event.target.value)"
/>
<i v-if="searchValue.length === 0" class="sn-icon sn-icon-search !m-2.5 !ml-auto right-0"></i>
<i v-else class="sn-icon sn-icon-close !m-2.5 !ml-auto right-0 cursor-pointer z-10"
@click="$emit('search:change', '')"></i>
</div>
<FilterDropdown v-if="filters.length" :filters="filters" @applyFilters="applyFilters" />
<FilterDropdown v-if="filters.length" :filters="filters" @applyFilters="applyFilters" :data-e2e="'e2e-BT-topToolbar-filters'"/>
<button
v-if="currentViewRender === 'table'"
@click="showColumnsModal = true"
:data-e2e="'e2e-BT-topToolbar-manageColumns'"
:title="i18n.t('experiments.table.column_display_modal.title')"
class="btn btn-light icon-btn btn-black"
>
@ -174,6 +179,11 @@ export default {
type: Object
}
},
mounted() {
if (this.searchValue.length > 0) {
this.openSearch();
}
},
components: {
MenuDropdown,
FilterDropdown,
@ -187,12 +197,14 @@ export default {
{
text: this.i18n.t('toolbar.active_state'),
url: this.activePageUrl,
active: this.currentViewMode === 'active'
active: this.currentViewMode === 'active',
data_e2e: 'e2e-BT-topToolbar-viewState-active'
},
{
text: this.i18n.t('toolbar.archived_state'),
url: this.archivedPageUrl,
active: this.currentViewMode === 'archived'
active: this.currentViewMode === 'archived',
data_e2e: 'e2e-BT-topToolbar-viewState-archived'
}
];
},
@ -202,9 +214,19 @@ export default {
const active = this.currentViewRender === type;
switch (type) {
case 'cards':
return { text: this.i18n.t('toolbar.cards_view'), emit: 'setCardsView', active };
return {
text: this.i18n.t('toolbar.cards_view'),
emit: 'setCardsView',
active,
data_e2e: 'e2e-BT-topToolbar-view-cards'
};
case 'table':
return { text: this.i18n.t('toolbar.table_view'), emit: 'setTableView', active };
return {
text: this.i18n.t('toolbar.table_view'),
emit: 'setTableView',
active,
data_e2e: 'e2e-BT-topToolbar-view-table'
};
case 'custom':
return { text: view.name, url: view.url, active };
default:

View file

@ -1,6 +1,6 @@
<template>
<div class="relative" v-click-outside="closeMenu" >
<div ref="field" class="cursor-pointer" @click.stop="isOpen = (!isOpen || fieldOnlyOpen)">
<div ref="field" class="cursor-pointer" @click.stop="toggleMenu">
<slot name="field"></slot>
</div>
<template v-if="isOpen">
@ -37,6 +37,7 @@ export default {
alwaysShow: { type: Boolean, default: false },
closeDropdown: { type: Boolean, default: false },
fieldOnlyOpen: { type: Boolean, default: false },
canOpen: { type: Boolean, default: true },
fixedWidth: { type: Boolean, default: false }
},
data() {
@ -61,6 +62,13 @@ export default {
}
},
methods: {
toggleMenu() {
if (this.canOpen && (!this.isOpen || this.fieldOnlyOpen)) {
this.isOpen = true;
} else if (this.isOpen && !this.fieldOnlyOpen) {
this.isOpen = false;
}
},
closeMenu(e) {
if (e && e.target.closest('.sn-dropdown, .sn-select-dropdown, .sn-menu-dropdown, .dp__instance_calendar')) return;
this.isOpen = false;

View file

@ -30,8 +30,8 @@
:placeholder="label || placeholder || this.i18n.t('general.select_dropdown.placeholder')"
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 max-w-[calc(100%-24px)]">
<div v-for="tag in tags" class=" truncate px-2 py-1 rounded-sm bg-sn-super-light-grey flex items-center gap-1">
<div v-else class="flex items-center gap-1 flex-wrap">
<div v-for="tag in tags" class="px-2 py-1 rounded-sm bg-sn-super-light-grey grid grid-cols-[auto_1fr] items-center gap-1">
<div class="truncate" v-if="labelRenderer" v-html="tag.label"></div>
<div class="truncate" v-else>{{ tag.label }}</div>
<i @click="removeTag(tag.value)" class="sn-icon mini ml-auto sn-icon-close cursor-pointer"></i>
@ -49,7 +49,7 @@
</div>
</div>
<i v-if="canClear" @click="clear" class="sn-icon ml-auto sn-icon-close"></i>
<i v-else class="sn-icon ml-auto self-start"
<i v-else class="sn-icon ml-auto"
:class="{ 'sn-icon-down': !isOpen, 'sn-icon-up': isOpen, 'text-sn-grey': disabled}"></i>
</div>
<template v-if="isOpen">
@ -72,7 +72,8 @@
<div
@click.stop="setValue(option[0])"
ref="options"
class="py-1.5 px-3 rounded cursor-pointer flex items-center gap-2 shrink-0"
:title="option[2]?.tooltip || option[1]"
class="py-1.5 px-3 rounded cursor-pointer flex items-center gap-2 shrink-0 hover:bg-sn-super-light-grey"
:class="[sizeClass, {
'!bg-sn-super-light-blue': valueSelected(option[0]) && focusedOption !== i,
'!bg-sn-super-light-grey': focusedOption === i ,
@ -181,6 +182,13 @@ export default {
tags() {
if (!this.newValue) return [];
this.selectAllState = 'indeterminate';
if (this.newValue.length === 0) {
this.selectAllState = 'unchecked';
} else if (this.newValue.length === this.rawOptions.length) {
this.selectAllState = 'checked';
}
return this.newValue.map((value) => {
const option = this.rawOptions.find((i) => i[0] === value);
return {

View file

@ -0,0 +1,33 @@
<template>
<div :title="text" class="flex items-center">
<template v-if="text.length <= endCharacters">
<div class="shrink-0">
{{ text }}
</div>
</template>
<template v-else>
<div class="truncate whitespace-pre">
{{ text.slice(0, endCharacters * -1) }}
</div>
<div class="shrink-0 whitespace-pre">
{{ text.slice(text.length - endCharacters) }}
</div>
</template>
</div>
</template>
<script>
export default {
name: 'StringWithEllipsis',
props: {
text: {
type: String,
required: true
},
endCharacters: {
type: Number,
default: 4
}
}
};
</script>

View file

@ -11,6 +11,7 @@ class Asset < ApplicationRecord
require 'tempfile'
# Lock duration set to 30 minutes
LOCK_DURATION = 60 * 30
SEARCHABLE_ATTRIBUTES = ['active_storage_blobs.filename', 'asset_text_data.data_vector'].freeze
enum view_mode: { thumbnail: 0, list: 1, inline: 2 }
@ -58,101 +59,31 @@ class Asset < ApplicationRecord
user,
include_archived,
query = nil,
page = 1,
_current_team = nil,
current_team = nil,
options = {}
)
teams = user.teams.select(:id)
teams = options[:teams] || current_team || user.teams.select(:id)
assets_in_steps = Asset.joins(:step).where(
'steps.id IN (?)',
Step.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.select(:id)
).pluck(:id)
assets_in_steps = Asset.joins(:step)
.where(steps: { protocol: Protocol.search(user, include_archived, nil, teams) })
.pluck(:id)
assets_in_results = Asset.joins(:result).where(
'results.id IN (?)',
Result.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.select(:id)
).pluck(:id)
assets_in_results = Asset.joins(:result)
.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_id IN (?)', teams).pluck(:id)
).where(repositories: { team: teams }).pluck(:id)
assets =
Asset.distinct
.where('assets.id IN (?) OR assets.id IN (?) OR assets.id IN (?)',
assets_in_steps, assets_in_results, assets_in_inventories)
assets = distinct.where('assets.id IN (?) OR assets.id IN (?) OR assets.id IN (?)',
assets_in_steps, assets_in_results, assets_in_inventories)
new_query = Asset.left_outer_joins(:asset_text_datum)
.joins(file_attachment: :blob)
.from(assets, 'assets')
a_query = s_query = ''
if options[:whole_word].to_s == 'true' ||
options[:whole_phrase].to_s == 'true'
like = options[:match_case].to_s == 'true' ? '~' : '~*'
s_query = query.gsub(/[!()&|:]/, ' ')
.strip
.split(/\s+/)
.map { |t| t + ':*' }
if options[:whole_word].to_s == 'true'
a_query = query.split
.map { |a| Regexp.escape(a) }
.join('|')
s_query = s_query.join('|')
else
a_query = Regexp.escape(query)
s_query = s_query.join('&')
end
a_query = '\\y(' + a_query + ')\\y'
s_query = s_query.tr('\'', '"')
new_query = new_query.where(
"(active_storage_blobs.filename #{like} ? " \
"OR asset_text_data.data_vector @@ plainto_tsquery(?))",
a_query,
s_query
)
else
like = options[:match_case].to_s == 'true' ? 'LIKE' : 'ILIKE'
a_query = query.split.map { |a| "%#{sanitize_sql_like(a)}%" }
# Trim whitespace and replace it with OR character. Make prefixed
# wildcard search term and escape special characters.
# For example, search term 'demo project' is transformed to
# 'demo:*|project:*' which makes word inclusive search with postfix
# wildcard.
s_query = query.gsub(/[!()&|:]/, ' ')
.strip
.split(/\s+/)
.map { |t| t + ':*' }
.join('|')
.tr('\'', '"')
new_query = new_query.where(
"(active_storage_blobs.filename #{like} ANY (array[?]) " \
"OR asset_text_data.data_vector @@ plainto_tsquery(?))",
a_query,
s_query
)
end
# Show all results if needed
if page != Constants::SEARCH_NO_LIMIT
new_query = new_query.select('assets.*, asset_text_data.data AS data')
.limit(Constants::SEARCH_LIMIT)
.offset((page - 1) * Constants::SEARCH_LIMIT)
Asset.select(
"assets_search.*, " \
"ts_headline(assets_search.data, plainto_tsquery('#{sanitize_sql_for_conditions(s_query)}'), " \
"'StartSel=<mark>, StopSel=</mark>') AS headline"
).from(new_query, 'assets_search')
else
new_query
end
Asset.left_outer_joins(:asset_text_datum)
.joins(file_attachment: :blob)
.from(assets, 'assets')
.where_attributes_like_boolean(SEARCHABLE_ATTRIBUTES, query, options)
end
def blob
@ -330,7 +261,7 @@ class Asset < ApplicationRecord
end
def get_action_url(user, action, with_tokens = true)
file_ext = file_name.split('.').last
file_ext = file_name.split('.').last&.downcase
action = get_action(file_ext, action)
if !action.nil?
action_url = action[:urlsrc]

View file

@ -31,28 +31,6 @@ class Checklist < ApplicationRecord
scope :asc, -> { order('checklists.created_at ASC') }
def self.search(user,
include_archived,
query = nil,
page = 1,
_current_team = nil,
options = {})
step_ids = Step.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.pluck(:id)
new_query = Checklist.distinct
.where(checklists: { step_id: step_ids })
.left_outer_joins(:checklist_items)
.where_attributes_like(['checklists.name', 'checklist_items.text'], query, options)
# Show all results if needed
if page == Constants::SEARCH_NO_LIMIT
new_query
else
new_query.limit(Constants::SEARCH_LIMIT).offset((page - 1) * Constants::SEARCH_LIMIT)
end
end
def duplicate(step, user, position = nil)
ActiveRecord::Base.transaction do
new_checklist = step.checklists.create!(

View file

@ -12,45 +12,6 @@ class Comment < ApplicationRecord
scope :unseen_by, ->(user) { where('? = ANY (unseen_by)', user.id) }
def self.search(
user,
include_archived,
query = nil,
page = 1,
_current_team = nil,
options = {}
)
project_ids = Project.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.pluck(:id)
my_module_ids = MyModule.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.pluck(:id)
step_ids = Step.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.pluck(:id)
result_ids = Result.search(user, include_archived, nil, Constants::SEARCH_NO_LIMIT)
.pluck(:id)
new_query = Comment.distinct
.joins(:user)
.where(
'(comments.associated_id IN (?) AND comments.type = ?) OR ' \
'(comments.associated_id IN (?) AND comments.type = ?) OR ' \
'(comments.associated_id IN (?) AND comments.type = ?) OR ' \
'(comments.associated_id IN (?) AND comments.type = ?)',
project_ids, 'ProjectComment',
my_module_ids, 'TaskComment',
step_ids, 'StepComment',
result_ids, 'ResultComment'
)
.where_attributes_like(['message', 'users.full_name'], query, options)
# Show all results if needed
if page == Constants::SEARCH_NO_LIMIT
new_query
else
new_query.limit(Constants::SEARCH_LIMIT).offset((page - 1) * Constants::SEARCH_LIMIT)
end
end
def self.mark_as_seen_by(user, commentable)
# rubocop:disable Rails/SkipsModelValidations
all.where('? = ANY (unseen_by)', user.id).update_all("unseen_by = array_remove(unseen_by, #{user.id.to_i}::bigint)")

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