Merge branch 'develop' into features/new_permissions

This commit is contained in:
Martin Artnik 2021-09-06 10:33:32 +02:00
commit 7e6ca3be8a
201 changed files with 4323 additions and 436 deletions

3
.gitignore vendored
View file

@ -35,6 +35,9 @@ ehthumbs.db
# Ignore temporary files
public/system/*
# Ignore BioEddie installion in public folder
public/bio_eddie/
# Ignore ActiveStorage Disc service storage directory
storage/

View file

@ -6,6 +6,7 @@ AllCops:
Exclude:
- "vendor/**/*"
- "db/schema.rb"
- "spec/**/*"
NewCops: enable
UseCache: false
TargetRubyVersion: 2.6
@ -351,7 +352,7 @@ Metrics/AbcSize:
Enabled: false
Metrics/BlockLength:
ExcludedMethods: ['describe', 'context']
IgnoredMethods: ['describe', 'context']
Metrics/ClassLength:
Enabled: false

View file

@ -1 +1 @@
1.22.2
1.22.4

View file

@ -0,0 +1,3 @@
<svg width="10" height="14" viewBox="0 0 10 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.40002 7.38092L6.29526 8.48568C6.37145 8.59996 6.44764 8.75235 6.48573 8.90473H5.68573C5.57145 8.67616 5.30478 8.52377 5.03811 8.52377C4.61906 8.52377 4.27621 8.86663 4.27621 9.28568C4.27621 9.70473 4.61906 10.0476 5.03811 10.0476C5.30478 10.0476 5.57145 9.8952 5.68573 9.66663H6.48573C6.33335 10.3142 5.72383 10.8095 5.00002 10.8095C4.69526 10.8095 4.39049 10.6952 4.16192 10.5428L3.62859 11.0762C4.00954 11.3809 4.50478 11.5714 5.03811 11.5714C5.30478 11.5714 5.53335 11.5333 5.76192 11.4571L6.33335 12.0285C5.9524 12.219 5.49526 12.3333 5.03811 12.3333C4.08573 12.3333 3.20954 11.8762 2.63811 11.1904L3.74287 10.0857C3.5524 9.85711 3.47621 9.59044 3.47621 9.28568C3.47621 8.44758 4.16192 7.76187 5.00002 7.76187C5.30478 7.76187 5.60954 7.87616 5.83811 8.02854L6.37145 7.4952C5.99049 7.19044 5.49525 6.99996 4.96192 6.99996C4.69526 6.99996 4.46668 7.03806 4.23811 7.11425L3.66668 6.54282C4.04764 6.35235 4.50478 6.23806 4.96192 6.23806C5.9524 6.23806 6.82859 6.6952 7.40002 7.38092ZM6.25716 4.90473V0.904727H7.01906V0.142822H5.49525V5.51425C7.36192 5.78092 8.80954 7.34282 8.80954 9.28568C8.80954 10.2381 8.46668 11.1143 7.89526 11.7619L7.36192 11.2285C7.78097 10.6952 8.04764 10.0095 8.04764 9.28568C8.04764 8.82854 7.93335 8.37139 7.74287 7.99044L7.17145 8.56187C7.24764 8.79044 7.28573 9.01901 7.28573 9.28568C7.28573 10.0476 6.90478 10.6952 6.37145 11.1143L7.47621 12.219C6.82859 12.7904 5.9524 13.0952 5.03811 13.0952C2.90478 13.0952 1.19049 11.3809 1.19049 9.28568C1.19049 8.3333 1.53335 7.45711 2.10478 6.80949L2.63811 7.34282C2.21907 7.87615 1.9524 8.56187 1.9524 9.28568C1.9524 9.74282 2.06668 10.2 2.25716 10.5809L2.82859 10.0095C2.7524 9.78092 2.7143 9.55235 2.7143 9.28568C2.7143 8.52377 3.09526 7.87616 3.62859 7.45711L2.52383 6.35235C3.05716 5.8952 3.74287 5.59044 4.46668 5.51425V0.142822H2.94287V0.904727H3.70478V4.90473C1.83811 5.43806 0.428589 7.19044 0.428589 9.28568C0.428589 11.8 2.48573 13.8571 5.00002 13.8571C7.5143 13.8571 9.57145 11.8 9.57145 9.28568C9.57145 7.19044 8.16192 5.43806 6.25716 4.90473Z" fill="#404048"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View file

@ -40,6 +40,8 @@
//= require users/settings/teams/invite_users_modal
//= require repository_columns/index
//= require perfect-scrollbar.min
//= require shared/inline_editing
//= require shared/barcode_search
//= require activestorage
//= require global_activities/side_pane
//= require protocols/header

View file

@ -1,4 +1,4 @@
/* global animateSpinner globalActivities */
/* global animateSpinner globalActivities HelperModule */
'use strict';
@ -59,6 +59,37 @@
});
}
function validateActivityFilterName() {
let filterName = $('#saveFilterModal .activity-filter-name-input').val();
$('#saveFilterModal .btn-confirm').prop('disabled', filterName.length === 0);
}
$('#saveFilterModal')
.on('keyup', '.activity-filter-name-input', function() {
validateActivityFilterName();
})
.on('click', '.btn-confirm', function() {
$.ajax({
url: this.dataset.saveFilterUrl,
type: 'POST',
global: false,
dataType: 'json',
data: {
name: $('#saveFilterModal .activity-filter-name-input').val(),
filter: globalActivities.getFilters()
},
success: function(data) {
HelperModule.flashAlertMsg(data.message, 'success');
$('#saveFilterModal .activity-filter-name-input').val('');
validateActivityFilterName();
$('#saveFilterModal').modal('hide');
},
error: function(response) {
HelperModule.flashAlertMsg(response.responseJSON.errors.join(','), 'danger');
}
});
});
initExpandCollapseAllButtons();
initShowMoreButton();
}());

View file

@ -0,0 +1,16 @@
(function() {
$('.api-key-input').on('keyup change', function() {
var initialValue = this.dataset.originalValue;
if (initialValue.length) {
if (initialValue !== this.value) {
$('.api-key-container').addClass('warning');
$('.save-button').removeClass('hidden');
$('.saved-button').addClass('hidden');
} else {
$('.api-key-container').removeClass('warning');
$('.save-button').addClass('hidden');
$('.saved-button').removeClass('hidden');
}
}
});
}());

View file

@ -172,6 +172,7 @@ var MyModuleRepositories = (function() {
var dataTableWrapper = $(tableContainer).closest('.dataTables_wrapper');
DataTableHelpers.initLengthApearance(dataTableWrapper);
DataTableHelpers.initSearchField(dataTableWrapper, I18n.t('repositories.show.filter_inventory_items'));
$('<img class="barcode-scanner" src="/images/icon_small/barcode.png"></img>').appendTo($('.search-container'));
dataTableWrapper.find('.main-actions, .pagination-row').removeClass('hidden');
if (options.assign_mode) {
renderFullViewAssignButtons();

View file

@ -65,6 +65,7 @@ var RepositoryDatatable = (function(global) {
$('#restoreRepositoryRecords').prop('disabled', true);
$('#deleteRepositoryRecords').prop('disabled', true);
$('#editDeleteCopy').hide();
$('#toolbarPrintLabel').hide();
} else {
if (rowsSelected.length === 1) {
$('#editRepositoryRecord').prop('disabled', false);
@ -82,6 +83,7 @@ var RepositoryDatatable = (function(global) {
$('#archiveRepositoryRecordsButton').prop('disabled', true);
}
$('#editDeleteCopy').show();
$('#toolbarPrintLabel').show();
}
} else if (currentMode === 'editMode') {
$(TABLE_WRAPPER_ID).addClass('editing');
@ -99,7 +101,10 @@ var RepositoryDatatable = (function(global) {
$('th').addClass('disable-click');
$('.repository-row-selector').prop('disabled', true);
$('.dataTables_filter input').prop('disabled', true);
$('#toolbarPrintLabel').hide();
}
$('#toolbarPrintLabel').data('rows', JSON.stringify(rowsSelected));
}
function clearRowSelection() {
@ -559,6 +564,8 @@ var RepositoryDatatable = (function(global) {
DataTableHelpers.initLengthApearance($(TABLE_ID).closest('.dataTables_wrapper'));
DataTableHelpers.initSearchField($(TABLE_ID).closest('.dataTables_wrapper'), I18n.t('repositories.show.filter_inventory_items'));
$('<img class="barcode-scanner" src="/images/icon_small/barcode.png"></img>').appendTo($('.search-container'));
if ($('.repository-show').length) {
$('.dataTables_scrollBody, .dataTables_scrollHead').css('overflow', '');
}

View file

@ -0,0 +1,21 @@
$(document).on('click', '.barcode-scanner', function() {
var search = $('.search-container .search-field');
var input = $('<input>').attr('type', 'text').css({ position: 'absolute', right: 0, opacity: 0 })
.appendTo($('.search-container').parent());
search.val('');
search.attr('disabled', true).addClass('barcode-mode');
input.focus();
input.one('change', function() {
search.val($(this).val());
search.trigger('keyup');
$(document).click();
});
setTimeout(function() {
$(document).one('click', function() {
search.attr('disabled', false).removeClass('barcode-mode');
input.remove();
});
});
});

View file

@ -0,0 +1,159 @@
/* global HelperModule I18n */
var bioEddieEditor = (function() {
var BIO_EDDIE;
var CHEMAXON;
var bioEddieIframe;
var bioEddieModal;
function importMolecule() {
var monomerModel = BIO_EDDIE.getMonomerModel();
var monomerImporter = new CHEMAXON.HelmImportModule();
var molecule = bioEddieModal.data('molecule');
if (molecule) {
monomerImporter.import(molecule, monomerModel)
.then(builder => BIO_EDDIE.setModel(builder.graphStoreData));
}
}
function loadBioEddie() {
BIO_EDDIE = bioEddieIframe.contentWindow.bioEddieEditor;
CHEMAXON = bioEddieIframe.contentWindow.chemaxon;
if (typeof BIO_EDDIE === 'undefined' || typeof CHEMAXON === 'undefined') {
setTimeout(function() {
loadBioEddie();
}, 2000);
} else {
importMolecule();
}
}
function initIframe() {
bioEddieIframe.src = bioEddieIframe.dataset.src;
loadBioEddie();
}
function saveMolecule(svg, structure, scheduleForRegistration) {
var moleculeName = bioEddieModal.find('.file-name input').val();
$.post(bioEddieModal.data('create-url'), {
description: structure,
schedule_for_registration: scheduleForRegistration,
object_id: bioEddieModal.data('object_id'),
object_type: bioEddieModal.data('object_type'),
name: moleculeName,
image: svg
}, function(result) {
var newAsset = $(result.html);
if (bioEddieModal.data('object_type') === 'Step') {
newAsset.find('.file-preview-link').css('top', '-300px');
newAsset.addClass('new').prependTo($(bioEddieModal.data('assets_container')));
setTimeout(function() {
newAsset.find('.file-preview-link').css('top', '0px');
}, 200);
bioEddieModal.modal('hide');
} else if (bioEddieModal.data('object_type') === 'Result') {
window.location.reload();
}
});
}
function updateMolecule(svg, structure, scheduleForRegistration) {
var moleculeName = bioEddieModal.find('.file-name input').val();
$.ajax({
url: bioEddieModal.data('update-url'),
data: {
description: structure,
schedule_for_registration: scheduleForRegistration,
name: moleculeName,
image: svg
},
dataType: 'json',
type: 'PUT',
success: function(json) {
$('#modal_link' + json.id + ' img').attr('src', json.url);
$('#modal_link' + json.id + ' .attachment-label').html(json.file_name);
bioEddieModal.modal('hide');
},
error: function(response) {
if (response.status === 403) {
HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger');
}
}
});
}
function generateImage(structure, scheduleForRegistration) {
var imageGenerator = new CHEMAXON.ImageGenerator();
var emptySVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
imageGenerator.generateSVGFromHelm(emptySVG, structure)
.then(svg => {
if (bioEddieModal.data('edit-mode')) {
updateMolecule(svg, structure, scheduleForRegistration);
} else {
saveMolecule(svg, structure, scheduleForRegistration);
}
})
.catch(() => {
if (structure === '$$$$V2.0') {
HelperModule.flashAlertMsg(I18n.t('bio_eddie.empty_molecule_error'), 'danger');
}
});
}
$(document).on('turbolinks:load', function() {
bioEddieIframe = document.getElementById('bioEddieIframe');
bioEddieModal = $('#bioEddieModal');
bioEddieModal.on('shown.bs.modal', function() {
initIframe();
});
bioEddieModal.on('click', '.file-save-link', function() {
var model = BIO_EDDIE.getModel();
var monomerModel = BIO_EDDIE.getMonomerModel();
var monomerExporter = new CHEMAXON.Helm2ExportModule();
var scheduleForRegistration = $(this).data('schedule-for-registration');
monomerExporter.export(model, monomerModel)
.then(structure => generateImage(structure, scheduleForRegistration));
});
});
return {
open_new: (objectId, objectType, container) => {
bioEddieModal.data('object_id', objectId);
bioEddieModal.data('object_type', objectType);
bioEddieModal.data('assets_container', container);
bioEddieModal.find('.file-name input').val('');
bioEddieModal.modal('show');
},
open_edit: (name, molecule, updateUrl) => {
bioEddieModal.data('edit-mode', true);
bioEddieModal.data('molecule', molecule);
bioEddieModal.data('update-url', updateUrl);
bioEddieModal.find('.file-name input').val(name);
bioEddieModal.modal('show');
}
};
}());
(function() {
$(document).on('click', '.new-bio-eddie-upload-button', function() {
bioEddieEditor.open_new(
this.dataset.objectId,
this.dataset.objectType,
this.dataset.assetsContainer
);
});
$(document).on('click', '.bio-eddie-edit-button', function() {
$('#filePreviewModal').modal('hide');
bioEddieEditor.open_edit(
this.dataset.moleculeName,
this.dataset.moleculeDescription,
this.dataset.updateUrl
);
$.post(this.dataset.editUrl);
});
}());

View file

@ -25,10 +25,10 @@ var DataTableHelpers = (function() {
initSearchField: function(dataTableWraper, searchText) {
var tableFilterInput = $(dataTableWraper).find('.dataTables_filter input');
tableFilterInput.attr('placeholder', searchText)
.addClass('sci-input-field')
.addClass('sci-input-field search-field')
.css('margin', 0);
$('.dataTables_filter').append(`
<div class="sci-input-container left-icon">
<div class="sci-input-container left-icon search-container">
<i class="fas fa-search"></i>
</div>`).find('.sci-input-container').prepend(tableFilterInput);
$('.dataTables_filter').find('label').remove();

View file

@ -48,10 +48,12 @@
*/
var dropdownSelector = (function() {
// /////////////////////
// Support functions //
// ////////////////////
const MAX_DROPDOWN_HEIGHT = 320;
// Change direction of dropdown depends of container position
function updateDropdownDirection(selector, container) {
@ -77,13 +79,15 @@ var dropdownSelector = (function() {
if ((modalContainerBottom + bottomSpace) < bottomTreshold) {
container.addClass('inverse');
container.find('.dropdown-container').css('max-height', `${(containerPosition - 122 + maxHeight)}px`)
maxHeight = Math.min(containerPosition - 122 + maxHeight, MAX_DROPDOWN_HEIGHT);
container.find('.dropdown-container').css('max-height', `${maxHeight}px`)
.css('margin-bottom', `${(containerPosition * -1)}px`)
.css('left', `${containerPositionLeft}px`)
.css('width', `${containerWidth}px`);
} else {
container.removeClass('inverse');
container.find('.dropdown-container').css('max-height', `${(bottomSpace - 32 + maxHeight)}px`)
maxHeight = Math.min(bottomSpace - 32 + maxHeight, MAX_DROPDOWN_HEIGHT);
container.find('.dropdown-container').css('max-height', `${maxHeight}px`)
.css('width', `${containerWidth}px`)
.css('left', `${containerPositionLeft}px`)
.css('margin-top', `${(bottomSpace * -1)}px`);
@ -254,8 +258,8 @@ var dropdownSelector = (function() {
updateTags(selector, container, { select: true });
}
// intialization keyboard control
function initKeyboardControl(container) {
// initialize keyboard control
function initKeyboardControl(selector, container) {
container.find('.search-field').keydown(function(e) {
var dropdownContainer = container.find('.dropdown-container');
var pressedKey = e.keyCode;
@ -265,20 +269,22 @@ var dropdownSelector = (function() {
dropdownContainer.find('.dropdown-option').first().addClass('highlight');
}
if (pressedKey === 38) {
if (pressedKey === 38) { // arrow up
if (selectedOption.prev('.dropdown-option').length) {
selectedOption.removeClass('highlight').prev().addClass('highlight');
}
if (selectedOption.prev('.delimiter').length) {
selectedOption.removeClass('highlight').prev().prev().addClass('highlight');
}
} else if (pressedKey === 40) {
} else if (pressedKey === 40) { // arrow down
if (selectedOption.next('.dropdown-option').length) {
selectedOption.removeClass('highlight').next().addClass('highlight');
}
if (selectedOption.next('.delimiter').length) {
selectedOption.removeClass('highlight').next().next().addClass('highlight');
}
} else if (pressedKey === 8 && e.target.value === '') { // backspace
deleteTag(selector, container, container.find('.ds-tags .fa-times').last());
}
});
}
@ -485,10 +491,10 @@ var dropdownSelector = (function() {
dropdownContainer.addClass('disable-search');
}
// initialization keyboard controll
initKeyboardControl(dropdownContainer);
// initialization keyboard control
initKeyboardControl(selector, dropdownContainer);
// In some case dropdown position not correclty calculated
// In some case dropdown position not correctly calculated
updateDropdownDirection(selectElement, dropdownContainer);
}
@ -663,6 +669,25 @@ var dropdownSelector = (function() {
updateTags(selector, container, { select: true });
}
function deleteTag(selector, container, target) {
var tagLabel = target.prev();
// Start delete animation
target.parent().addClass('closing');
// Add timeout for deleting animation
setTimeout(() => {
if (selector.data('combine-tags')) {
// if we use combine-tags options we simply clear all values
container.find('.data-field').val('[]');
updateTags(selector, container);
} else {
// Or delete specific one
deleteValue(selector, container, tagLabel.data('ds-tag-id'), tagLabel.data('ds-tag-group'));
}
}, 350);
}
// Refresh tags in input field
function updateTags(selector, container, config = {}) {
var selectedOptions = getCurrentData(container);
@ -696,22 +721,8 @@ var dropdownSelector = (function() {
// Now we need add delete action to tag
tag.find('.fa-times').click(function(e) {
var tagLabel = $(this).prev();
var toDelete;
e.stopPropagation();
// Start delete animation
$(this).parent().addClass('closing');
// Add timeout for deleting animation
setTimeout(() => {
if (selector.data('combine-tags')) {
// if we use combine-tags optons we simply clear all values
container.find('.data-field').val('[]');
updateTags(selector, container);
} else {
// Or delete specific one
deleteValue(selector, container, tagLabel.data('ds-tag-id'), tagLabel.data('ds-tag-group'));
}
}, 350);
deleteTag(selector, container, $(this));
});
}

View file

@ -0,0 +1,32 @@
function updateProgressModal() {
var status;
var modal = $(document).find('.label-printing-progress-modal');
if (modal.length === 0) {
return;
}
$.getJSON(
`/label_printers/${modal.data('labelPrinterId')}/update_progress_modal`
+ `?starting_item_count=${modal.data('startingItemCount')}`,
function(data) {
modal.replaceWith(data.html);
status = modal.data('label-printer-status');
if (status !== 'done' && status !== 'error') {
setTimeout(updateProgressModal, 3000);
}
}
);
}
$(document).on('click', '.label-printing-progress-modal .close', function() {
$(this).closest('.label-printing-progress-modal').remove();
});
$(document).on('turbolinks:load', function() {
var modal = $(document).find('.label-printing-progress-modal');
if (modal.length > 0) {
updateProgressModal();
}
});

View file

@ -1,3 +1,5 @@
/* global dropdownSelector bwipjs */
(function() {
'use strict';
@ -16,6 +18,15 @@
$(this).find('.modal-body #repository_row-info-table').DataTable().destroy();
$(this).remove();
});
let barCodeCanvas = bwipjs.toCanvas('bar-code-canvas', {
bcid: 'qrcode',
text: $('#modal-info-repository-row #bar-code-canvas').data('id').toString(),
scale: 3
});
$('#modal-info-repository-row #bar-code-image').attr('src', barCodeCanvas.toDataURL('image/png'));
$('#repository_row-info-table').DataTable({
dom: 'RBltpi',
stateSave: false,
@ -40,4 +51,28 @@
e.preventDefault();
return false;
});
$(document).on('click', '.print-label-button', function() {
$.ajax({
method: 'GET',
url: $(this).data('url'),
data: { rows: JSON.parse($(this).data('rows')) },
dataType: 'json'
}).done(function(xhr, settings, data) {
$('body').append($.parseHTML(data.responseJSON.html));
$('#modal-print-repository-row-label').modal('show', {
backdrop: true,
keyboard: false
}).on('hidden.bs.modal', function() {
$(this).remove();
});
dropdownSelector.init('#modal-print-repository-row-label #label_printer_id', {
noEmptyOption: true,
singleSelect: true,
closeOnSelect: true,
selectAppearance: 'simple'
});
});
});
})();

View file

@ -61,6 +61,14 @@
}
});
dropdownSelector.init('#role', {
noEmptyOption: true,
singleSelect: true,
closeOnSelect: true,
selectAppearance: 'simple',
disableSearch: true
});
modal.off('show.bs.modal').on('show.bs.modal', function() {
// This cannot be scoped outside this function
// because it is generated via JS
@ -121,7 +129,7 @@
var data = {
emails: dropdownSelector.getValues(emailsInput),
team_ids: dropdownSelector.getValues(teamsInput),
role: roleInput.val(),
role: dropdownSelector.getValues(roleInput),
'g-recaptcha-response': $('#recaptcha-invite-modal').val()
};

View file

@ -0,0 +1,74 @@
/* global dropdownSelector */
(function() {
function initDeleteFilterModal() {
$('.activity-filters-list').on('click', '.delete-filter', function() {
$('#deleteFilterModal').find('.description b').text(this.dataset.name);
$('#deleteFilterModal').find('#filter_id').val(this.dataset.id);
$('#deleteFilterModal').modal('show');
});
}
function initFilterInfoDropdown() {
$('.info-container').on('show.bs.dropdown', function() {
var tagsList = $(this).find('.tags-list');
if (tagsList.is(':empty')) {
$.get(this.dataset.url, function(data) {
$.each(data.filter_elements, function(i, element) {
let tag = $('<span class="filter-info-tag"></span>');
tag.text(element);
tagsList.append(tag);
});
});
}
});
}
$('.activity-filters-list').on('ajax:error', '.webhook-form', function(e, data) {
$(this).renderFormErrors('webhook', data.responseJSON.errors);
});
$('.activity-filters-list').on('click', '.create-webhook', function() {
let filterElement = $(this).closest('.filter-element');
filterElement.find('.webhooks-list').collapse('show');
filterElement.find('.create-webhook-container').removeClass('hidden');
});
$('.activity-filters-list').on('click', '.create-webhook-container .cancel-action', function(e) {
let webhookContainer = $(this).closest('.create-webhook-container');
e.preventDefault();
webhookContainer.addClass('hidden');
webhookContainer.find('.url-input').val('');
$('.webhook-form').renderFormErrors('webhook', [], true);
});
$('.activity-filters-list').on('click', '.edit-webhook', function(e) {
let webhookContainer = $(this).closest('.webhook');
e.preventDefault();
webhookContainer.find('.view-mode').addClass('hidden');
webhookContainer.find('.edit-webhook-container').removeClass('hidden');
});
$('.activity-filters-list').on('click', '.edit-webhook-container .cancel-action', function(e) {
let webhookContainer = $(this).closest('.webhook');
let input = webhookContainer.find('.url-input');
e.preventDefault();
input.val(input.data('original-value'));
webhookContainer.find('.view-mode').removeClass('hidden');
webhookContainer.find('.edit-webhook-container').addClass('hidden');
$('.webhook-form').renderFormErrors('webhook', [], true);
});
$('.webhook-method-container select').each(function() {
dropdownSelector.init($(this), {
singleSelect: true,
closeOnSelect: true,
noEmptyOption: true,
selectAppearance: 'simple',
disableSearch: true
});
});
initDeleteFilterModal();
initFilterInfoDropdown();
}());

View file

@ -0,0 +1,80 @@
// scss-lint:disable SelectorDepth
// scss-lint:disable NestingDepth
// scss-lint:disable SelectorFormat
// scss-lint:disable ImportantRule
// scss-lint:disable IdSelector
@import "constants";
@import "mixins";
// MarvinJs modal
.modal-bio-eddie {
background: transparent;
font-size: $font-size-base;
padding: 0 !important;
.modal-dialog {
height: 100%;
margin: 0;
padding: 0;
width: auto;
}
.modal-content {
background: transparent;
border: 0;
box-shadow: none;
height: 100%;
width: auto;
}
.modal-header {
background: $color-white;
display: flex;
height: 60px;
line-height: 40px;
padding: 10px 15px;
text-align: center;
.file-save-link {
flex-shrink: 0;
margin: 0 20px 0 0;
img {
height: 16px;
vertical-align: sub;
}
}
.file-name {
align-items: center;
display: flex;
flex-shrink: 0;
float: left;
margin-right: auto;
input {
border-radius: 5px;
box-shadow: none;
color: $color-black;
height: 40px;
margin-left: 5px;
outline: 0;
padding: 5px 10px;
position: relative;
width: 350px;
}
}
}
.modal-body {
height: calc(100% - 60px);
padding: 0;
iframe {
height: 100%;
position: relative;
width: 100%;
}
}
}

View file

@ -21,7 +21,8 @@
}
.create-wopi-file-btn,
.new-marvinjs-upload-button {
.new-marvinjs-upload-button,
.new-bio-eddie-upload-button {
padding: 0;
}
}

View file

@ -514,12 +514,14 @@ li.module-hover {
}
}
.users-dropdown-list {
.dropdown-option.users-dropdown-list {
padding: 8px 10px;
.item-avatar {
border-radius: 50%;
height: 20px;
margin: 0 .5em 0 0;
width: 20px;
height: 32px;
margin: 0 16px 0 0;
width: 32px;
}
}
}

View file

@ -579,3 +579,21 @@
max-height: 290px;
}
}
.barcode-scanner {
cursor: pointer;
position: absolute;
right: 12px;
top: 12px;
}
.search-container {
.search-field {
padding-right: 32px;
&.barcode-mode {
background-color: $brand-primary;
opacity: .3;
}
}
}

View file

@ -0,0 +1,66 @@
.label-printing-progress-modal {
background: $color-white;
bottom: 1em;
box-shadow: $modal-shadow;
min-width: 300px;
position: fixed;
right: 1em;
z-index: 9999;
.modal-header {
align-items: center;
display: flex;
padding: .5em;
.title {
@include font-h3;
}
.printer-status {
border: $border-default;
color: $color-silver-chalice;
margin-left: .5em;
margin-right: auto;
padding: .25em;
&[data-status="ready"] {
background: $brand-success;
border-color: $brand-success;
color: $color-white;
}
&[data-status="out_of_labels"] {
background: $brand-warning;
border-color: $brand-warning;
color: $color-white;
}
&[data-status="error"] {
background: $brand-danger;
border-color: $brand-danger;
color: $color-white;
}
}
}
.modal-body {
.printing-items {
.id-label {
margin-left: .5em;
opacity: .5;
}
}
.printing-status {
color: $brand-primary;
&[data-status="done"] {
color: $brand-success;
}
&[data-status="waiting_labels"], &[data-status="error"] {
color: $brand-danger;
}
}
}
}

View file

@ -0,0 +1,31 @@
#modal-print-repository-row-label {
.id-label {
margin-left: .5em;
opacity: .5;
}
.printers-container {
margin-bottom: 1em;
min-height: 4em;
}
.print-copies-input {
margin-left: .5em;
width: 50px;
}
.modal-footer {
text-align: center;
}
.no-printers-container {
padding: 2em;
text-align: center;
.no-printer-title {
@include font-h3;
margin-top: 1em;
}
}
}

View file

@ -0,0 +1,6 @@
#modal-info-repository-row {
.bar-code-container {
display: flex;
justify-content: flex-end;
}
}

View file

@ -1,3 +1,6 @@
// scss-lint:disable SelectorDepth SelectorFormat QualifyingElement
// scss-lint:disable NestingDepth ImportantRule
.user-account-addons {
.content-pane {
margin: 0;
@ -6,6 +9,54 @@
.addons-title {
border-bottom: $border-tertiary;
margin-bottom: 0;
padding-bottom: 15px;
}
.addons-subtitle {
margin-top: 2em;
}
.printers-container {
.printer {
border: $border-default;
padding: 1em;
.header {
align-items: center;
display: flex;
margin-bottom: .5em;
.title {
font-weight: bold;
}
.control {
margin-left: auto;
}
.status {
border: $border-default;
color: $color-silver-chalice;
margin-left: .5em;
margin-right: auto;
padding: .25em;
&[data-ready="true"] {
background: $brand-success;
border-color: $brand-success;
color: $color-white;
}
}
.fas-check {
margin-left: .25em;
}
}
.description {
margin-bottom: .5em;
}
}
}
}

View file

@ -0,0 +1,89 @@
// scss-lint:disable SelectorDepth SelectorFormat QualifyingElement
// scss-lint:disable NestingDepth ImportantRule
.label-printer-show {
.printer-title {
flex-grow: 0 !important;
margin-right: .5em !important;
}
.status {
border: $border-default;
color: $color-silver-chalice;
margin-left: .5em;
margin-right: auto;
padding: .25em;
&[data-ready="true"] {
background: $brand-success;
border-color: $brand-success;
color: $color-white;
}
}
ul {
list-style-type: none;
padding-left: 0;
li {
padding: .5em 0;
}
}
.collapse {
margin-bottom: 1em;
}
.fa-caret-down {
cursor: pointer;
margin-right: .25em;
&.collapsed {
@include rotate(-90deg);
}
}
.collapse-row {
align-items: center;
display: flex;
}
.collapse {
padding-left: 1.5em;
}
.row-title {
@include font-h2;
margin-left: .5em;
}
.api-key-container {
display: flex;
position: relative;
&.warning {
&::after {
color: $brand-primary-light;
content: attr(data-warning);
left: 0;
position: absolute;
bottom: -1.5em;
}
}
.api-key-input {
margin-right: .5em;
}
.btn {
margin-left: .5em;
margin-top: 23px;
}
}
.update-printers {
margin-left: auto;
}
}

View file

@ -189,14 +189,15 @@
}
}
.users-dropdown-list {
.dropdown-option.users-dropdown-list {
border-top: 1px solid $color-gainsboro;
padding: 8px 10px;
.item-avatar {
border-radius: 50%;
height: 20px;
margin: 0 .5em 0 0;
width: 20px;
height: 32px;
margin: 0 16px 0 0;
width: 32px;
}
.item-email {

View file

@ -0,0 +1,166 @@
// scss-lint:disable SelectorDepth NestingDepth
.webhooks-index {
.webhooks-description {
@include font-main;
margin: 1em 0;
}
.activity-filters-list {
padding: 0;
}
.filter-element {
border-left: 3px solid $color-concrete;
list-style: none;
margin: 1em 0;
}
.filter-block {
align-items: center;
display: flex;
padding-left: 1em;
.fa-caret-down {
cursor: pointer;
margin-right: 1em;
&.collapsed {
@include rotate(-90deg);
}
}
.create-webhook {
margin-left: auto;
}
.filter-name {
@include font-h3;
margin-right: .5em;
}
.info-container {
.dropdown-menu {
padding: .5em;
width: 400px;
}
}
.filter-info-title {
@include font-small;
padding-left: .25em;
}
.tags-list {
display: flex;
flex-wrap: wrap;
.filter-info-tag {
@include font-small;
background: $color-concrete;
flex-shrink: 0;
margin: .25em;
padding: .25em;
}
}
}
.webhooks-list {
list-style: none;
padding-top: .5em;
}
.webhook-form {
align-items: center;
display: flex;
.form-group {
margin: 0;
}
.form-text {
flex-shrink: 0;
}
.webhook-method-container {
margin: .5em;
}
.url-input-container {
margin: .5em;
}
}
.webhook {
border-top: $border-tertiary;
padding: .5em 0;
.view-mode {
align-items: center;
display: flex;
.method {
margin: 0 .5em;
}
.webhook-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.active-webhook,
.disabled-webhook {
flex-basis: 110px;
flex-shrink: 0;
margin-left: auto;
padding: 0 1em;
text-align: right;
.fas {
margin-right: .25em;
}
}
.active-webhook {
color: $brand_success;
}
.dropdown-menu {
@include font-button;
.fas {
margin-right: .25em;
}
.divider-label {
@include font-small;
color: $color-silver-chalice;
padding-left: 1em;
}
a {
border-radius: unset;
cursor: pointer;
padding: .5em 1em;
&:hover {
background: $color-concrete;
}
}
}
}
&:not(.active) {
.view-mode {
color: $color-silver-chalice;
}
}
}
#deleteFilterModal {
.delete-filter-form {
display: inline-block;
}
}
}

View file

@ -61,6 +61,34 @@
}
}
&.bio_eddie {
&::before,
&::after {
border-radius: 1em 0 0 1em;
bottom: 1em;
content: "";
display: block;
height: 2em;
line-height: 2em;
position: absolute;
right: -1em;
width: 2.25em;
}
&::before {
background: $bio-eddie-color;
}
&::after {
background-image: url("/images/icon_small/bio_eddie_white.png");
background-repeat: no-repeat;
height: 1.85em;
right: -1.15em;
width: 2em;
}
}
.fas {
font-size: 100px;
line-height: 160px;

View file

@ -115,7 +115,7 @@
.dropdown-menu {
@include font-button;
min-width: 150px;
min-width: 200px;
padding: .5em 0;
a {
@ -136,6 +136,10 @@
right: 1em;
}
}
.fa-stack {
width: 2em;
}
}
}
}

View file

@ -18,7 +18,6 @@
.dropdown-selector-container {
display: inline-block;
float: left;
position: relative;
width: 100%;
@ -104,7 +103,6 @@
.tag-label {
display: inline-block;
margin-right: 5px;
margin-top: 1px;
max-width: 240px;
overflow: hidden;
text-align: left;
@ -115,6 +113,12 @@
&[data-ds-tag-id=""] {
opacity: .7;
}
.item-avatar {
height: 16px;
margin-right: 8px;
width: 16px;
}
}
.fas {

View file

@ -41,6 +41,9 @@ $office-ms-powerpoint: #d24726;
// MarvinJS color:
$marvinjs-color: #29999c;
// BioEddie color:
$bio-eddie-color: #ffa000;
$pdf-color: #f40f02;
// Don't use them

View file

@ -1,3 +1,11 @@
.modal-sm {
width: 370px;
}
.modal {
.modal-absolute-close-button {
position: absolute;
right: 1em;
top: .5em;
}
}

View file

@ -133,6 +133,10 @@ body {
}
}
.modal-header h4 {
font-size: 18px;
}
.jumbotron {
background-color: inherit;
}
@ -1349,14 +1353,7 @@ body > .loading-overlay {
margin-top: 20px;
}
h4 {
font-size: 14px;
margin-bottom: 5px;
}
.select-container--with-blank {
overflow: hidden;
.search-field::placeholder {
color: $color-black;
opacity: 1;

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ActiveJobsController < ApplicationController
def status
render json: { status: ApplicationJob.status(params[:id]) }
end
end

View file

@ -7,8 +7,8 @@ module Api
class IDMismatchError < StandardError; end
class IncludeNotSupportedError < StandardError; end
class PermissionError < StandardError
attr_reader :klass
attr_reader :mode
attr_reader :klass, :mode
def initialize(klass, mode)
@klass = klass
@mode = mode

View file

@ -16,11 +16,11 @@ module Api
.page(params.dig(:page, :number))
.per(params.dig(:page, :size))
render jsonapi: projects, each_serializer: ProjectSerializer
render jsonapi: projects, each_serializer: ProjectSerializer, include: include_params
end
def show
render jsonapi: @project, serializer: ProjectSerializer
render jsonapi: @project, serializer: ProjectSerializer, include: include_params
end
def create
@ -64,6 +64,10 @@ module Api
params.require(:data).require(:attributes).permit(:name, :visibility, :archived, :project_folder_id)
end
def permitted_includes
%w(comments)
end
def load_project_for_managing
@project = @team.projects.find(params.require(:id))
raise PermissionError.new(Project, :manage) unless can_manage_project?(@project)

View file

@ -12,7 +12,7 @@ module Api
.page(params.dig(:page, :number))
.per(params.dig(:page, :size))
render jsonapi: results, each_serializer: ResultSerializer,
include: %i(text table file)
include: (%i(text table file) << include_params).flatten.compact
end
def create
@ -43,7 +43,7 @@ module Api
def show
render jsonapi: @result, serializer: ResultSerializer,
include: %i(text table file)
include: (%i(text table file) << include_params).flatten.compact
end
private
@ -185,6 +185,10 @@ module Api
prms
end
def permitted_includes
%w(comments)
end
def convert_old_tiny_mce_format(text)
text.scan(/\[~tiny_mce_id:(\w+)\]/).flatten.each do |token|
old_format = /\[~tiny_mce_id:#{token}\]/

View file

@ -20,11 +20,17 @@ module Api
.page(params.dig(:page, :number))
.per(params.dig(:page, :size))
render jsonapi: tasks, each_serializer: TaskSerializer, rte_rendering: render_rte?, team: @team
render jsonapi: tasks, each_serializer: TaskSerializer,
include: include_params,
rte_rendering: render_rte?,
team: @team
end
def show
render jsonapi: @task, serializer: TaskSerializer, rte_rendering: render_rte?, team: @team
render jsonapi: @task, serializer: TaskSerializer,
include: include_params,
rte_rendering: render_rte?,
team: @team
end
def create
@ -69,6 +75,10 @@ module Api
params.require(:data).require(:attributes).permit(%i(name x y description my_module_status_id))
end
def permitted_includes
%w(comments)
end
def load_task_for_managing
@task = @experiment.my_modules.find(params.require(:id))
raise PermissionError.new(MyModule, :manage) unless can_manage_module?(@task)

View file

@ -212,6 +212,22 @@ class AssetsController < ApplicationController
def destroy
if @asset.destroy
case @assoc
when Step
if @assoc.protocol.in_module?
log_step_activity(:edit_step, @assoc, @assoc.my_module.experiment.project, my_module: @assoc.my_module.id)
else
log_step_activity(
:edit_step_in_protocol_repository,
@assoc,
nil,
protocol: @assoc.protocol.id
)
end
when Result
log_result_activity(:edit_result, @assoc)
end
render json: { flash: I18n.t('assets.file_deleted', file_name: @asset.file_name) }
else
render json: {}, status: :unprocessable_entity
@ -265,4 +281,31 @@ class AssetsController < ApplicationController
'file'
end
def log_step_activity(type_of, step, project = nil, message_items = {})
default_items = { step: step.id,
step_position: { id: step.id, value_for: 'position_plus_one' } }
message_items = default_items.merge(message_items)
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: step.protocol,
team: current_team,
project: project,
message_items: message_items)
end
def log_result_activity(type_of, result)
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: result,
team: result.my_module.experiment.project.team,
project: result.my_module.experiment.project,
message_items: {
result: result.id,
type_of_result: t('activities.result_type.text')
})
end
end

View file

@ -0,0 +1,133 @@
# frozen_string_literal: true
class BioEddieAssetsController < ApplicationController
include BioEddieActions
include ActiveStorage::SetCurrent
before_action :load_vars, except: %i(create bmt_request license)
before_action :load_create_vars, only: :create
before_action :check_read_permission, except: %i(update create start_editing bmt_request license)
before_action :check_edit_permission, only: %i(update create start_editing)
def create
asset = BioEddieService.create_molecule(bio_eddie_params, current_user, current_team)
create_create_bio_eddie_activity(asset, current_user)
if asset && bio_eddie_params[:object_type] == 'Step'
create_register_bio_eddie_activity(asset, current_user) if bio_eddie_params[:schedule_for_registration] == 'true'
render json: {
html: render_to_string(partial: 'assets/asset.html.erb', locals: {
asset: asset,
gallery_view_id: bio_eddie_params[:object_id]
})
}
elsif asset && bio_eddie_params[:object_type] == 'Result'
create_register_bio_eddie_activity(asset, current_user) if bio_eddie_params[:schedule_for_registration] == 'true'
render json: { status: 'created' }, status: :ok
else
render json: asset.errors, status: :unprocessable_entity
end
end
def update
asset = BioEddieService.update_molecule(bio_eddie_params, current_team)
create_edit_bio_eddie_activity(asset, current_user, :finish_editing)
if asset
create_register_bio_eddie_activity(asset, current_user) if bio_eddie_params[:schedule_for_registration] == 'true'
render json: { url: rails_representation_url(asset.medium_preview),
id: asset.id,
file_name: asset.blob.metadata['name'] }
else
render json: { error: t('bio_eddie.no_molecules_found') }, status: :unprocessable_entity
end
end
def license
license_file_path = Rails.root.join('data/bioeddie/license.cxl')
if File.file?(license_file_path)
send_file(license_file_path)
else
render_404
end
end
def bmt_request
return render_404 unless ENV['BIOMOLECULE_TOOLKIT_BASE_URL']
uri = URI.parse(ENV['BIOMOLECULE_TOOLKIT_BASE_URL'])
uri.path = request.original_fullpath.remove('/biomolecule_toolkit')
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
api_request = "Net::HTTP::#{request.request_method.capitalize}".constantize.new(uri)
api_request['x-api-key'] = ENV['BIOMOLECULE_TOOLKIT_API_KEY'] if ENV['BIOMOLECULE_TOOLKIT_API_KEY']
api_request['Content-Type'] = 'application/json'
request_body = request.body.read
api_request.body = request_body if request_body.present?
api_response = http.request(api_request)
render body: api_response.body, content_type: api_response.content_type, status: api_response.code
end
end
def start_editing
create_edit_bio_eddie_activity(@asset, current_user, :start_editing)
end
private
def load_vars
@asset = current_team.assets.find_by(id: params[:id])
return render_404 unless @asset
@assoc = @asset.step || @asset.result
case @assoc
when Step
@protocol = @assoc.protocol
when Result
@my_module = @assoc.my_module
end
end
def load_create_vars
case bio_eddie_params[:object_type]
when 'Step'
@assoc = Step.find_by(id: bio_eddie_params[:object_id])
@protocol = @assoc.protocol
when 'Result'
@assoc = MyModule.find_by(id: bio_eddie_params[:object_id])
@my_module = @assoc
end
end
def check_read_permission
case @assoc
when Step
return render_403 unless can_read_protocol_in_module?(@protocol) ||
can_read_protocol_in_repository?(@protocol)
when Result, MyModule
return render_403 unless can_read_experiment?(@my_module.experiment)
else
render_403
end
end
def check_edit_permission
case @assoc
when Step
return render_403 unless can_manage_protocol_in_module?(@protocol) ||
can_manage_protocol_in_repository?(@protocol)
when Result, MyModule
return render_403 unless can_manage_module?(@my_module)
else
render_403
end
end
def bio_eddie_params
params.permit(:id, :description, :object_id, :object_type, :name, :image, :schedule_for_registration)
end
end

View file

@ -0,0 +1,111 @@
# frozen_string_literal: true
module BioEddieActions
extend ActiveSupport::Concern
private
def create_edit_bio_eddie_activity(asset, current_user, started_editing)
action = case started_editing
when :start_editing
t('activities.file_editing.started')
when :finish_editing
t('activities.file_editing.finished')
end
return unless bio_eddie_asset_validation(asset)
bio_eddie_find_target_object(asset, current_user, 'edit', action)
end
def create_create_bio_eddie_activity(asset, current_user)
return unless bio_eddie_asset_validation(asset)
bio_eddie_find_target_object(asset, current_user, 'create')
end
def create_register_bio_eddie_activity(asset, current_user)
return unless bio_eddie_asset_validation(asset)
bio_eddie_find_target_object(asset, current_user, 'register')
end
def bio_eddie_asset_validation(asset)
asset && asset.file.metadata[:asset_type] == 'bio_eddie'
end
def bio_eddie_asset_type(asset, klass)
return true if asset.step_asset&.step.instance_of?(klass)
return true if asset.result_asset&.result.instance_of?(klass)
false
end
def bio_eddie_find_target_object(asset, current_user, activity_type, action = nil)
if bio_eddie_asset_type(asset, Step)
bio_eddie_step_activity(asset, current_user, activity_type, action)
elsif bio_eddie_asset_type(asset, Result)
bio_eddie_result_activity(asset, current_user, activity_type, action)
end
end
def bio_eddie_step_activity(asset, current_user, activity, action = nil)
step = asset.step_asset&.step
asset_type = 'asset_name'
protocol = step&.protocol
return unless step && protocol
default_step_items = {
step: step.id,
step_position: { id: step.id, value_for: 'position_plus_one' },
asset_type => { id: asset.id, value_for: 'file_name' },
description: asset.blob.metadata['description'],
name: asset.blob.metadata['name']
}
default_step_items[:action] = action if action
if protocol.in_module?
project = protocol.my_module.experiment.project
team = project.team
type_of = "#{activity}_molecule_on_step".to_sym
message_items = { my_module: protocol.my_module.id }
else
type_of = "#{activity}_molecule_on_step_in_repository".to_sym
team = protocol.team
message_items = { protocol: protocol.id }
end
message_items = default_step_items.merge(message_items)
Activities::CreateActivityService
.call(activity_type: type_of,
owner: current_user,
subject: protocol,
team: team,
project: project,
message_items: message_items)
end
def bio_eddie_result_activity(asset, current_user, activity, action = nil)
result = asset.result_asset&.result
asset_type = 'asset_name'
my_module = result&.my_module
return unless result && my_module
message_items = {
result: result.id,
asset_type => { id: asset.id, value_for: 'file_name' },
description: asset.blob.metadata['description'],
name: asset.blob.metadata['name']
}
message_items[:action] = action if action
Activities::CreateActivityService
.call(activity_type: "#{activity}_molecule_on_result".to_sym,
owner: current_user,
subject: result,
team: my_module.experiment.project.team,
project: my_module.experiment.project,
message_items: message_items)
end
end

View file

@ -3,6 +3,8 @@
class GlobalActivitiesController < ApplicationController
include InputSanitizeHelper
before_action :check_create_activity_filter_permissions, only: :save_activity_filter
def index
# Preload filter format
# {
@ -106,8 +108,21 @@ class GlobalActivitiesController < ApplicationController
render json: get_objects(Report)
end
def save_activity_filter
activity_filter = ActivityFilter.new(activity_filter_params)
if activity_filter.save
render json: { message: t('global_activities.index.activity_filter_saved') }
else
render json: { errors: activity_filter.errors.full_messages }, status: :unprocessable_entity
end
end
private
def check_create_activity_filter_permissions
render_403 && return unless can_create_acitivity_filters?
end
def get_objects(subject)
query = subject_search_params[:query]
teams =
@ -138,6 +153,10 @@ class GlobalActivitiesController < ApplicationController
matched.map { |pr| { value: pr[0], label: escape_input(pr[1]) } }
end
def activity_filter_params
params.permit(:name, filter: {})
end
def activity_filters
params.permit(
:page, :starting_timestamp, :from_date, :to_date, types: [], subjects: {}, users: [], teams: []

View file

@ -0,0 +1,122 @@
# frozen_string_literal: true
class LabelPrintersController < ApplicationController
include InputSanitizeHelper
before_action :check_manage_permissions, except: %i(index update_progress_modal)
before_action :find_label_printer, only: %i(edit update destroy)
def index
@label_printers = LabelPrinter.all
@fluics_api_key = @label_printers.any? ? @label_printers.first.fluics_api_key : nil
end
def new
@label_printer = LabelPrinter.new
end
def edit; end
def create
@label_printer = LabelPrinter.new(label_printer_params)
if @label_printer.save
flash[:success] = t('label_printers.create.success', { printer_name: @label_printer.name })
redirect_to edit_label_printer_path(@label_printer)
else
flash[:error] = t('label_printers.create.error', { printer_name: @label_printer.name })
render :new
end
end
def update
if @label_printer.update(label_printer_params)
flash[:success] = t('label_printers.update.success', { printer_name: @label_printer.name })
redirect_to edit_label_printer_path(@label_printer)
else
flash[:error] = t('label_printers.update.error', { printer_name: @label_printer.name })
render :edit
end
end
def destroy
if @label_printer.destroy
flash[:success] = t('label_printers.destroy.success', { printer_name: @label_printer.name })
else
flash[:error] = t('label_printers.destroy.error', { printer_name: @label_printer.name })
end
redirect_to addons_path
end
def print
print_job = LabelPrinters::PrintJob.perform_later(
LabelPrinter.find(params[:id]),
LabelTemplate.find(print_job_params[:label_template_id])
.render(print_job_params[:locals])
)
render json: { job_id: print_job.job_id }
end
def update_progress_modal
render(
json: {
html:
render_to_string(
partial: 'label_printers/print_progress_modal',
locals: {
starting_item_count: params[:starting_item_count].to_i,
label_printer: LabelPrinter.find(params[:id])
}
)
}
)
end
def create_fluics
begin
printers = LabelPrinters::Fluics::ApiClient.new(label_printer_params[:fluics_api_key]).list
LabelPrinter.destroy_all
printers.each do |fluics_printer|
label_printer = LabelPrinter.find_or_initialize_by(
fluics_api_key: label_printer_params[:fluics_api_key],
fluics_lid: fluics_printer['LID'],
type_of: :fluics,
language_type: :zpl
)
label_printer.update(
name: fluics_printer['serviceName'],
description: fluics_printer['comment']
)
end
rescue LabelPrinters::Fluics::ApiClient::BadRequestError
flash[:error] = t('users.settings.account.label_printer.api_key_error')
end
redirect_to label_printers_path
end
private
def check_manage_permissions
render_403 unless can_manage_label_printers?
end
def label_printer_params
params.require(:label_printer).permit(
:name, :type_of, :fluics_api_key, :host, :port
)
end
def print_job_params
params.require(:label_template_id, :label_template_locals)
end
def find_label_printer
@label_printer = LabelPrinter.find(params[:id])
end
end

View file

@ -372,7 +372,6 @@ class ReportsController < ApplicationController
.accessible_by_teams(current_team)
.name_like(search_params[:q])
.limit(Constants::SEARCH_LIMIT)
.select(:id, :name, :team_id, :permission_level)
repositories.each do |repository|
next unless can_manage_repository_rows?(current_user, repository)

View file

@ -49,6 +49,8 @@ class RepositoriesController < ApplicationController
@display_delete_button = can_delete_repository_rows?(@repository)
@display_duplicate_button = can_create_repository_rows?(@repository)
@snapshot_provisioning = @repository.repository_snapshots.provisioning.any?
@busy_printer = LabelPrinter.where.not(current_print_job_ids: []).first
end
def table_toolbar

View file

@ -4,6 +4,8 @@ class RepositoryRowsController < ApplicationController
include ApplicationHelper
include MyModulesHelper
MAX_PRINTABLE_ITEM_NAME_LENGTH = 64
before_action :load_repository, except: :show
before_action :load_repository_row, only: %i(update assigned_task_list)
before_action :check_read_permissions, except: %i(show create update delete_records copy_records)
@ -71,6 +73,45 @@ class RepositoryRowsController < ApplicationController
end
end
def print_modal
@repository_rows = @repository.repository_rows.where(id: params[:rows])
@printers = LabelPrinter.all
respond_to do |format|
format.json do
render json: {
html: render_to_string(
partial: 'repositories/print_label_modal.html.erb'
)
}
end
end
end
def print
# reset all potential error states for printers and discard all jobs
# rubocop:disable Rails/SkipsModelValidations
LabelPrinter.update_all(status: :ready, current_print_job_ids: [])
# rubocop:enable Rails/SkipsModelValidations
label_printer = LabelPrinter.find(params[:label_printer_id])
job_ids = RepositoryRow.where(id: params[:repository_row_ids]).flat_map do |repository_row|
LabelPrinters::PrintJob.perform_later(
label_printer,
LabelTemplate.first.render( # Currently we will only use the default template
item_id: repository_row.code,
item_name: repository_row.name.truncate(MAX_PRINTABLE_ITEM_NAME_LENGTH)
),
params[:copies].to_i
).job_id
end
label_printer.update!(current_print_job_ids: job_ids * params[:copies].to_i)
redirect_to repository_path(@repository)
end
def update
row_update = RepositoryRows::UpdateRepositoryRowService
.call(repository_row: @repository_row, user: current_user, params: update_params)

View file

@ -533,6 +533,10 @@ class StepsController < ApplicationController
marvin_js_assets_attributes: %i(
id
_destroy
),
bio_eddie_assets_attributes: %i(
id
_destroy
)
)
end

View file

@ -70,7 +70,7 @@ module Users
break
end
result = { email: email }
result = { email: email, user_teams: [] }
unless Constants::BASIC_EMAIL_REGEX.match?(email)
result[:status] = :user_invalid
@invite_results << result

View file

@ -38,7 +38,7 @@ module Users
return redirect_to after_omniauth_failure_path_for(resource_name)
end
user = User.find_by(email: email)
user = User.find_by(email: email.downcase)
if user.blank?
# Create new user and identity

View file

@ -3,6 +3,10 @@ module Users
module Account
class AddonsController < ApplicationController
layout 'fluid'
def index
@label_printer_any = LabelPrinter.any?
end
end
end
end

View file

@ -0,0 +1,101 @@
# frozen_string_literal: true
module Users
module Settings
class WebhooksController < ApplicationController
layout 'fluid'
before_action :can_manage_filters
before_action :load_filter, except: :index
before_action :load_webhook, only: %i(update destroy)
before_action :set_sort, except: :filter_info
def index
@activity_filters = ActivityFilter.includes(:webhooks).order(name: (@current_sort == 'atoz' ? :asc : :desc))
end
def destroy_filter
@filter.destroy
redirect_to users_settings_webhooks_path(sort: @current_sort)
end
def filter_info
render json: { filter_elements: load_filter_elements(@filter) }
end
def create
@webhook = @filter.webhooks.create(webhook_params)
if @webhook.errors.any?
render json: { errors: @webhook.errors.messages }, status: :unprocessable_entity
else
flash[:success] = t('webhooks.index.webhook_created')
redirect_to users_settings_webhooks_path(sort: @current_sort)
end
end
def update
@webhook.update(webhook_params)
if @webhook.errors.any?
render json: { errors: @webhook.errors.messages }, status: :unprocessable_entity
else
flash[:success] = t('webhooks.index.webhook_updated')
redirect_to users_settings_webhooks_path(sort: @current_sort)
end
end
def destroy
@webhook.destroy
flash[:success] = t('webhooks.index.webhook_deleted')
redirect_to users_settings_webhooks_path(sort: @current_sort)
end
private
def can_manage_filters
render_403 && return unless can_create_acitivity_filters?
end
def set_sort
@current_sort = params[:sort] || 'atoz'
end
def load_filter
@filter = ActivityFilter.find_by(id: params[:filter_id])
render_404 && return unless @filter
end
def load_webhook
@webhook = Webhook.find_by(id: params[:id])
render_404 && return unless @webhook
end
def webhook_params
params.require(:webhook).permit(:http_method, :url, :active)
end
def load_filter_elements(filter)
result = []
filters = filter.filter
result += Team.where(id: filters['teams']).pluck(:name) if filters['teams']
result += User.where(id: filters['users']).pluck(:full_name) if filters['users']
if filters['types']
result += Activity.type_ofs.select { |_k, v| filters['types'].include?(v.to_s) }
.map { |k, _v| I18n.t("global_activities.activity_name.#{k}") }
end
if filters['to_date'] || filters['from_date']
result.push("#{t('global_activities.index.period_label')} #{filters['from_date']} - #{filters['to_date']}")
end
filters['subjects']&.each do |subject, ids|
result += subject.constantize.where(id: ids).pluck(:name)
end
result
end
end
end
end

View file

@ -23,7 +23,7 @@ module LeftMenuBarHelper
end
def settings_are_selected?
controller_name.in? %(registrations preferences addons teams connected_accounts)
controller_name.in? %(registrations preferences addons teams connected_accounts webhooks)
end
def activities_are_selected?

View file

@ -25,7 +25,7 @@ module NotificationsHelper
)
if target_user.assignments_notification
UserNotification.create(notification: notification, user: target_user)
notification.create_user_notification(target_user)
end
end
end

View file

@ -23,6 +23,11 @@ module UserSettingsHelper
action_name.in?(%w(index new create show audits_index))
end
def on_settings_webhook_page?
controller_name.in?(%w(webhooks)) &&
action_name.in?(%w(index))
end
def on_settings_account_connected_accounts_page?
controller_name == 'connected_accounts'
end

View file

@ -0,0 +1 @@
window.bwipjs = require('bwip-js');

View file

@ -44,7 +44,7 @@ export default {
info_label: "Info"
},
invite_users: {
modal_title: "Invite users to team {team}",
modal_title: "Invite members to {team}",
input_text: "Invite more people to team {team} and start using SciNote.",
input_help:
"Input one or multiple emails, confirm each email with ENTER key.",

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Activities
class DispatchWebhooksJob < ApplicationJob
queue_as :high_priority
def perform(activity)
webhooks =
Webhook.active.where(
activity_filter_id:
Activities::ActivityFilterMatchingService.new(activity).activity_filters.select(:id)
)
webhooks.each do |webhook|
Activities::SendWebhookJob.perform_later(webhook, activity)
end
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Activities
class SendWebhookJob < ApplicationJob
queue_as :webhooks
retry_on StandardError, attempts: 3, wait: :exponentially_longer
def perform(webhook, activity)
Activities::ActivityWebhookService.new(webhook, activity).send_webhook
end
end
end

View file

@ -6,4 +6,14 @@ class ApplicationJob < ActiveJob::Base
# Most jobs are safe to ignore if the underlying records are no longer available
discard_on ActiveJob::DeserializationError
def self.status(job_id)
delayed_job = Delayed::Job.where('handler LIKE ?', "%job_id: #{job_id}%").last
return :done unless delayed_job
return :failed if delayed_job.failed_at
return :running if delayed_job.locked_at
:pending
end
end

View file

@ -0,0 +1,43 @@
# frozen_string_literal: true
module LabelPrinters
class PrintJob < ApplicationJob
MAX_STATUS_UPDATES = 10
queue_as :high_priority
discard_on(StandardError) do |job, _error|
label_printer = job.arguments.first
label_printer.update!(status: :error)
end
def perform(label_printer, payload, copy_count)
case label_printer.type_of
when 'fluics'
api_client = LabelPrinters::Fluics::ApiClient.new(
label_printer.fluics_api_key
)
copy_count.times do
response = api_client.print(label_printer.fluics_lid, payload)
status = response['status'] == 'OK' ? :ready : LabelPrinter::FLUICS_STATUS_MAP[response['printerStatus']]
label_printer.update!(status: status)
break if status != :ready
# remove first matching job_id from queue (one label out of batch has been printed)
label_printer.with_lock do
job_ids = label_printer.current_print_job_ids
job_ids.delete_at(job_ids.index(job_id) || job_ids.length)
label_printer.update!(current_print_job_ids: job_ids)
end
end
end
# mark as unreachable if no final state is reached
label_printer.update!(status: :unreachable) unless label_printer.status.in? %w(ready out_of_labels error)
end
end
end

View file

@ -1,6 +1,17 @@
# frozen_string_literal: true
class Activity < ApplicationRecord
ASSIGNMENT_TYPES = %w(
assign_user_to_project
change_user_role_on_project
unassign_user_from_project
assign_user_to_module
unassign_user_from_module
invite_user_to_team
remove_user_from_team
change_users_role_on_team
).freeze
include ActivityValuesModel
include GenerateNotificationModel
@ -63,6 +74,9 @@ class Activity < ApplicationRecord
breadcrumbs: {}
)
after_create ->(activity) { Activities::DispatchWebhooksJob.perform_later(activity) },
if: -> { Rails.application.config.x.webhooks_enabled }
def self.activity_types_list
activity_list = type_ofs.map do |key, value|
[
@ -145,6 +159,12 @@ class Activity < ApplicationRecord
when ProjectFolder
breadcrumbs[:project_folder] = subject.name
generate_breadcrumb(subject.team)
when Step
breadcrumbs[:step] = subject.name
generate_breadcrumb(subject.protocol)
when Asset
breadcrumbs[:asset] = subject.blob.filename.to_s
generate_breadcrumb(subject.result || subject.step || subject.repository_cell.repository_row.repository)
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ActivityFilter < ApplicationRecord
validates :name, presence: true
validates :filter, presence: true
has_many :webhooks, dependent: :destroy
end

View file

@ -238,6 +238,10 @@ class Asset < ApplicationRecord
file.metadata[:asset_type] == 'marvinjs'
end
def bio_eddie?
file.metadata[:asset_type] == 'bio_eddie' || File.extname(file_name) == '.helm'
end
def pdf_preview_ready?
return false if pdf_preview_processing
@ -447,6 +451,10 @@ class Asset < ApplicationRecord
return convert_variant_to_base64(medium_preview) if style == :medium
end
def my_module
(result || step)&.my_module
end
private
def tempdir

View file

@ -15,7 +15,7 @@ module GenerateNotificationModel
description = generate_notification_description_elements(subject).reverse.join(' | ')
notification = Notification.create(
type_of: :recent_changes,
type_of: notification_type,
title: sanitize_input(message, %w(strong a)),
message: sanitize_input(description, %w(strong a)),
generator_user_id: owner.id
@ -114,4 +114,14 @@ module GenerateNotificationModel
def generate_notification
CreateNotificationFromActivityJob.perform_later(self) if notifiable?
end
def notification_type
return :recent_changes unless instance_of?(Activity)
if type_of.in? Activity::ASSIGNMENT_TYPES
:assignment
else
:recent_changes
end
end
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
class LabelPrinter < ApplicationRecord
FLUICS_STATUS_MAP = Hash.new(:error).merge(
{
'00' => :ready,
'50' => :busy,
'60' => :busy,
'01' => :out_of_labels,
'02' => :out_of_labels
}
).freeze
enum type_of: { fluics: 0 }
enum language_type: { zpl: 0 }
enum status: { ready: 0, busy: 1, out_of_labels: 2, unreachable: 3, error: 4 }
validates :name, presence: true
validates :type_of, presence: true
validates :language_type, presence: true
def display_name
"#{name}#{description}"
end
def done?
current_print_job_ids.blank? && ready?
end
def printing?
current_print_job_ids.any? && ready?
end
def printing_status
return 'printing' if printing?
return 'done' if done?
status
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class LabelTemplate < ApplicationRecord
enum language_type: { zpl: 0 }
validates :name, presence: true
validates :size, presence: true
validates :content, presence: true
def render(locals)
locals.reduce(content.dup) do |rendered_content, (key, value)|
rendered_content.gsub!(/\{\{#{key}\}\}/, value.to_s)
end
end
end

View file

@ -192,6 +192,7 @@ class TeamZipExport < ZipExport
elements.each_with_index do |element, i|
asset = element.asset
preview = prepare_preview(asset)
bio_eddie = asset.file.metadata[:asset_type] == 'bio_eddie'
if type == :step
name = "#{directory}/" \
"#{append_file_suffix(asset.file_name, "_#{i}_Step#{element.step.position_plus_one}")}"
@ -199,17 +200,28 @@ class TeamZipExport < ZipExport
preview_name = "#{directory}/" \
"#{append_file_suffix(preview[:file_name], "_#{i}_Step#{element.step.position_plus_one}_preview")}"
end
if bio_eddie
bio_eddie_name = "#{directory}/" \
"#{append_file_suffix("#{asset.file.metadata[:name]}.heml", "_#{i}_Step#{element.step.position_plus_one}")}"
end
elsif type == :result
name = "#{directory}/#{append_file_suffix(asset.file_name, "_#{i}")}"
preview_name = "#{directory}/#{append_file_suffix(preview[:file_name], "_#{i}_preview")}" if preview
bio_eddie_name = "#{directory}/#{append_file_suffix("#{asset.file.metadata[:name]}.heml", "_#{i}_preview")}" if bio_eddie
end
if asset.file.attached?
File.open(name, 'wb') { |f| f.write(asset.file.download) }
File.open(preview_name, 'wb') { |f| f.write(preview[:file_data]) } if preview
if bio_eddie
File.open(bio_eddie_name, 'wb') { |f| f.write(asset.file.metadata[:description]) }
end
end
asset_indexes[asset.id] = {
file: name,
preview: preview_name
preview: preview_name,
bio_eddie: bio_eddie_name
}
end
asset_indexes

View file

@ -46,6 +46,8 @@ class User < ApplicationRecord
}
}.freeze
DEFAULT_OTP_DRIFT_TIME_SECONDS = 10
store_accessor :variables, :export_vars
default_variables(
@ -291,6 +293,7 @@ class User < ApplicationRecord
foreign_key: :resource_owner_id,
dependent: :delete_all
before_validation :downcase_email!
before_destroy :destroy_notifications
def name
@ -621,7 +624,10 @@ class User < ApplicationRecord
raise StandardError, 'Missing otp_secret' unless otp_secret
totp = ROTP::TOTP.new(otp_secret, issuer: 'sciNote')
totp.verify(otp, drift_behind: 10)
totp.verify(
otp,
drift_behind: ENV.fetch('OTP_DRIFT_TIME_SECONDS', DEFAULT_OTP_DRIFT_TIME_SECONDS).to_i
)
end
def assign_2fa_token!
@ -674,6 +680,12 @@ class User < ApplicationRecord
private
def downcase_email!
return unless email
self.email = email.downcase
end
def destroy_notifications
# Find all notifications where user is the only reference
# on the notification, and destroy all such notifications

27
app/models/webhook.rb Normal file
View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Webhook < ApplicationRecord
enum http_method: { get: 0, post: 1, patch: 2 }
belongs_to :activity_filter
validates :http_method, presence: true
validates :url, presence: true
validate :enabled?
validate :valid_url
scope :active, -> { where(active: true) }
private
def enabled?
unless Rails.application.config.x.webhooks_enabled
errors.add(:configuration, I18n.t('activerecord.errors.models.webhook.attributes.configuration.disabled'))
end
end
def valid_url
unless /\A#{URI::DEFAULT_PARSER.make_regexp(%w(http https))}\z/.match?(url)
errors.add(:url, I18n.t('activerecord.errors.models.webhook.attributes.url.not_valid'))
end
end
end

View file

@ -8,5 +8,13 @@ module Organization
can :create_teams do |_|
true
end
can :manage_label_printers do |_|
true
end
can :create_acitivity_filters do
Rails.application.config.x.webhooks_enabled
end
end
end

View file

@ -19,6 +19,8 @@ module Api
belongs_to :subject, polymorphic: true
belongs_to :owner, key: :user, serializer: UserSerializer
include TimestampableModel
def message
if object.old_activity?
object.message

View file

@ -9,6 +9,8 @@ module Api
attributes :id, :file_name, :file_size, :file_type, :file_url
belongs_to :step, serializer: StepSerializer
include TimestampableModel
def file_type
object.content_type
end

View file

@ -5,6 +5,8 @@ module Api
class ChecklistItemSerializer < ActiveModel::Serializer
type :checklist_items
attributes :id, :text, :checked, :position
include TimestampableModel
end
end
end

View file

@ -6,6 +6,8 @@ module Api
type :checklists
attributes :id, :name
has_many :checklist_items, serializer: ChecklistItemSerializer
include TimestampableModel
end
end
end

View file

@ -26,6 +26,8 @@ module Api
serializer: ResultSerializer,
if: -> { object.class == ResultComment &&
!instance_options[:hide_result] }
include TimestampableModel
end
end
end

View file

@ -11,6 +11,8 @@ module Api
belongs_to :to, key: :output_task,
serializer: TaskSerializer,
class_name: 'MyModule'
include TimestampableModel
end
end
end

View file

@ -5,6 +5,8 @@ module Api
class ExperimentSerializer < ActiveModel::Serializer
type :experiments
attributes :id, :name, :description, :archived
include TimestampableModel
end
end
end

View file

@ -7,6 +7,8 @@ module Api
attributes :id, :value_type, :value
attribute :repository_column_id, key: :column_id
include TimestampableModel
def value
ActiveModelSerializers::SerializableResource.new(
object.value,

View file

@ -5,6 +5,8 @@ module Api
class InventoryChecklistItemSerializer < ActiveModel::Serializer
type :inventory_checklist_items
attributes :id, :data
include TimestampableModel
end
end
end

View file

@ -30,6 +30,8 @@ module Api
!instance_options[:hide_list_items]
end)
include TimestampableModel
def data_type
Extends::API_REPOSITORY_DATA_TYPE_MAPPINGS[object.data_type]
end

View file

@ -13,6 +13,8 @@ module Api
serializer: InventorySerializer,
class_name: 'Repository',
if: -> { instance_options[:show_repository] }
include TimestampableModel
end
end
end

View file

@ -5,6 +5,8 @@ module Api
class InventoryListItemSerializer < ActiveModel::Serializer
type :inventory_list_items
attribute :data
include TimestampableModel
end
end
end

View file

@ -6,6 +6,8 @@ module Api
type :inventories
attributes :id, :name
belongs_to :created_by, serializer: UserSerializer
include TimestampableModel
end
end
end

View file

@ -5,6 +5,8 @@ module Api
class InventoryStatusItemSerializer < ActiveModel::Serializer
type :inventory_status_items
attributes :status, :icon
include TimestampableModel
end
end
end

View file

@ -10,6 +10,8 @@ module Api
belongs_to :parent_folder, serializer: ProjectFolderSerializer
has_many :projects, serializer: ProjectSerializer
has_many :project_folders, serializer: ProjectFolderSerializer
include TimestampableModel
end
end
end

View file

@ -7,6 +7,9 @@ module Api
attributes :name, :visibility, :start_date, :archived
belongs_to :project_folder, serializer: ProjectFolderSerializer
has_many :project_comments, key: :comments, serializer: CommentSerializer
include TimestampableModel
def start_date
object.created_at

View file

@ -5,6 +5,8 @@ module Api
class ProtocolKeywordSerializer < ActiveModel::Serializer
type :protocol_keywords
attributes :id, :name
include TimestampableModel
end
end
end

View file

@ -17,6 +17,8 @@ module Api
has_many :steps, serializer: StepSerializer, if: -> { object.steps.any? }
belongs_to :parent, serializer: ProtocolSerializer, if: -> { object.parent.present? }
include TimestampableModel
def description
if instance_options[:rte_rendering]
custom_auto_link(object.tinymce_render(:description),

View file

@ -15,6 +15,8 @@ module Api
belongs_to :project, serializer: ProjectSerializer,
unless: -> { instance_options[:hide_project] }
include TimestampableModel
def pdf_file_size
object.pdf_file.blob.byte_size
end

View file

@ -5,6 +5,8 @@ module Api
class RepositoryAssetValueSerializer < ActiveModel::Serializer
attributes :file_id, :file_name, :file_size, :url
include TimestampableModel
def file_id
object.asset&.id
end

View file

@ -9,6 +9,8 @@ module Api
attribute :inventory_checklist_item_names do
object.repository_checklist_items.pluck(:data)
end
include TimestampableModel
end
end
end

View file

@ -5,6 +5,8 @@ module Api
class RepositoryDateRangeValueSerializer < ActiveModel::Serializer
attribute :date_range
include TimestampableModel
def date_range
{
from: object.start_time.to_date,

View file

@ -5,6 +5,8 @@ module Api
class RepositoryDateTimeRangeValueSerializer < ActiveModel::Serializer
attribute :date_time_range
include TimestampableModel
def date_time_range
{
from: object.start_time,

View file

@ -5,6 +5,8 @@ module Api
class RepositoryDateTimeValueSerializer < ActiveModel::Serializer
attribute :date_time
include TimestampableModel
def date_time
object.data
end

View file

@ -5,6 +5,8 @@ module Api
class RepositoryDateValueSerializer < ActiveModel::Serializer
attribute :date
include TimestampableModel
def date
object.data.to_date
end

View file

@ -5,6 +5,8 @@ module Api
class RepositoryListValueSerializer < ActiveModel::Serializer
attribute :repository_list_item_id, key: :inventory_list_item_id
attribute :formatted, key: :inventory_list_item_name
include TimestampableModel
end
end
end

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