mirror of
https://github.com/scinote-eln/scinote-web.git
synced 2024-09-20 06:35:56 +08:00
Merge branch 'develop' into features/inventory-import-improvements
This commit is contained in:
commit
bc6e43052f
|
@ -299,6 +299,9 @@ Naming/VariableName:
|
|||
Naming/VariableNumber:
|
||||
EnforcedStyle: normalcase
|
||||
|
||||
Naming/BlockForwarding:
|
||||
EnforcedStyle: explicit
|
||||
|
||||
Style/WordArray:
|
||||
EnforcedStyle: percent
|
||||
MinSize: 0
|
||||
|
|
5
Gemfile
5
Gemfile
|
@ -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'
|
||||
|
|
64
Gemfile.lock
64
Gemfile.lock
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 + ' '}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 %>
|
||||
};
|
||||
|
|
|
@ -104,4 +104,8 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('turbolinks:load', () => {
|
||||
$('#itemLandingPagelink').trigger('click');
|
||||
});
|
||||
}());
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]) &&
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 = ''
|
||||
|
|
|
@ -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)
|
||||
|
|
10
app/javascript/packs/vue/global_search.js
Normal file
10
app/javascript/packs/vue/global_search.js
Normal 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');
|
12
app/javascript/packs/vue/repository_item_error_sidebar.js
Normal file
12
app/javascript/packs/vue/repository_item_error_sidebar.js
Normal 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');
|
|
@ -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;
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
319
app/javascript/vue/global_search/container.vue
Normal file
319
app/javascript/vue/global_search/container.vue
Normal 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>
|
234
app/javascript/vue/global_search/filters.vue
Normal file
234
app/javascript/vue/global_search/filters.vue
Normal 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>
|
152
app/javascript/vue/global_search/filters/date.vue
Normal file
152
app/javascript/vue/global_search/filters/date.vue
Normal 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>
|
49
app/javascript/vue/global_search/filters_modal.vue
Normal file
49
app/javascript/vue/global_search/filters_modal.vue
Normal 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>
|
50
app/javascript/vue/global_search/groups/assets.vue
Normal file
50
app/javascript/vue/global_search/groups/assets.vue
Normal 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>
|
45
app/javascript/vue/global_search/groups/experiments.vue
Normal file
45
app/javascript/vue/global_search/groups/experiments.vue
Normal 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>
|
45
app/javascript/vue/global_search/groups/folders.vue
Normal file
45
app/javascript/vue/global_search/groups/folders.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
17
app/javascript/vue/global_search/groups/helpers/list_end.vue
Normal file
17
app/javascript/vue/global_search/groups/helpers/list_end.vue
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
46
app/javascript/vue/global_search/groups/label_templates.vue
Normal file
46
app/javascript/vue/global_search/groups/label_templates.vue
Normal 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>
|
|
@ -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>
|
46
app/javascript/vue/global_search/groups/my_modules.vue
Normal file
46
app/javascript/vue/global_search/groups/my_modules.vue
Normal 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>
|
46
app/javascript/vue/global_search/groups/projects.vue
Normal file
46
app/javascript/vue/global_search/groups/projects.vue
Normal 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>
|
46
app/javascript/vue/global_search/groups/protocols.vue
Normal file
46
app/javascript/vue/global_search/groups/protocols.vue
Normal 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>
|
47
app/javascript/vue/global_search/groups/reports.vue
Normal file
47
app/javascript/vue/global_search/groups/reports.vue
Normal 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>
|
46
app/javascript/vue/global_search/groups/repository_rows.vue
Normal file
46
app/javascript/vue/global_search/groups/repository_rows.vue
Normal 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>
|
46
app/javascript/vue/global_search/groups/results.vue
Normal file
46
app/javascript/vue/global_search/groups/results.vue
Normal 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>
|
140
app/javascript/vue/global_search/groups/search_mixin.js
Normal file
140
app/javascript/vue/global_search/groups/search_mixin.js
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
26
app/javascript/vue/global_search/loader.vue
Normal file
26
app/javascript/vue/global_search/loader.vue
Normal 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>
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
332
app/javascript/vue/navigation/quick_search.vue
Normal file
332
app/javascript/vue/navigation/quick_search.vue
Normal 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>
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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`);
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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') }}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
33
app/javascript/vue/shared/string_with_ellipsis.vue
Normal file
33
app/javascript/vue/shared/string_with_ellipsis.vue
Normal 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>
|
|
@ -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]
|
||||
|
|
|
@ -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!(
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue