diff --git a/.hound.yml b/.hound.yml index 38500c3c7..655c2729f 100644 --- a/.hound.yml +++ b/.hound.yml @@ -1,6 +1,6 @@ ruby: config_file: .rubocop.yml - version: 0.75.0 + version: 0.83.0 eslint: enabled: true diff --git a/.rubocop.yml b/.rubocop.yml index 629a3d4aa..4d61db2bb 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,6 +6,7 @@ AllCops: Exclude: - "vendor/**/*" - "db/schema.rb" + NewCops: enable UseCache: false TargetRubyVersion: 2.6 diff --git a/Gemfile b/Gemfile index 6a8a3d018..5a7bb6ae3 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,7 @@ gem 'jsonapi-renderer', '~> 0.2.2' gem 'jwt', '~> 1.5' gem 'kaminari' gem 'rack-attack' +gem 'rack-cors' # JS datetime library, requirement of datetime picker gem 'momentjs-rails', '~> 2.17.1' @@ -123,7 +124,7 @@ group :development, :test do gem 'pry-rails' gem 'rails-controller-testing' gem 'rspec-rails', '>= 4.0.0.beta2' - gem 'rubocop', '>= 0.75.0', require: false + gem 'rubocop', '= 0.83.0', require: false gem 'rubocop-performance' gem 'rubocop-rails' gem 'timecop' diff --git a/Gemfile.lock b/Gemfile.lock index eec130753..865903db8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,7 +17,7 @@ GIT GIT remote: https://github.com/biosistemika/yomu - revision: 8845246f3e6a6cbc49b902cd4b908ba70553cbdd + revision: 063b855d672e9dd9de1e6e585b349a9b63e120c3 branch: master specs: yomu (0.2.4) @@ -110,7 +110,7 @@ GEM ajax-datatables-rails (0.3.1) railties (>= 3.1) aspector (0.14.0) - ast (2.4.0) + ast (2.4.1) auto_strip_attributes (2.5.0) activerecord (>= 4.0) autoprefixer-rails (9.7.0) @@ -287,7 +287,6 @@ GEM mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.13, < 3) iniparse (1.4.4) - jaro_winkler (1.5.4) jbuilder (2.9.1) activesupport (>= 4.2.0) jmespath (1.4.0) @@ -338,9 +337,9 @@ GEM marcel (0.3.3) mimemagic (~> 0.3.2) method_source (0.9.2) - mime-types (3.3) + mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2019.0904) + mime-types-data (3.2020.0512) mimemagic (0.3.5) mini_magick (4.9.5) mini_mime (1.0.2) @@ -387,9 +386,9 @@ GEM overcommit (0.49.1) childprocess (>= 0.6.3, < 2.0) iniparse (~> 1.4) - parallel (1.19.1) - parser (2.6.5.0) - ast (~> 2.4.0) + parallel (1.19.2) + parser (2.7.1.4) + ast (~> 2.4.1) pg (1.1.4) pg_search (2.3.0) activerecord (>= 4.2) @@ -410,6 +409,8 @@ GEM rack (2.2.3) rack-attack (6.1.0) rack (>= 1.0, < 3) + rack-cors (1.1.1) + rack (>= 2.0.0) rack-proxy (0.6.5) rack rack-test (1.1.0) @@ -463,6 +464,7 @@ GEM responders (3.0.0) actionpack (>= 5.0) railties (>= 5.0) + rexml (3.2.4) rgl (0.5.6) lazy_priority_queue (~> 0.1.0) stream (~> 0.5.2) @@ -492,13 +494,13 @@ GEM rspec-mocks (~> 3.8) rspec-support (~> 3.8) rspec-support (3.8.2) - rubocop (0.78.0) - jaro_winkler (~> 1.5.1) + rubocop (0.83.0) parallel (~> 1.10) - parser (>= 2.6) + parser (>= 2.7.0.1) rainbow (>= 2.2.2, < 4.0) + rexml ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 1.7) + unicode-display_width (>= 1.4.0, < 2.0) rubocop-performance (1.5.1) rubocop (>= 0.71.0) rubocop-rails (2.4.0) @@ -569,7 +571,7 @@ GEM uglifier (4.2.0) execjs (>= 0.3.0, < 3) underscore-rails (1.8.3) - unicode-display_width (1.6.0) + unicode-display_width (1.7.0) uniform_notifier (1.12.1) warden (1.2.8) rack (>= 2.0.6) @@ -666,6 +668,7 @@ DEPENDENCIES pry-rails puma rack-attack + rack-cors rails (~> 6.0.0) rails-controller-testing rails_12factor @@ -676,7 +679,7 @@ DEPENDENCIES rotp rqrcode rspec-rails (>= 4.0.0.beta2) - rubocop (>= 0.75.0) + rubocop (= 0.83.0) rubocop-performance rubocop-rails ruby-graphviz (~> 1.2) diff --git a/VERSION b/VERSION index 2a4feaf54..0044d6cb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.19.6 +1.20.1 diff --git a/app/assets/javascripts/dashboard/current_tasks.js b/app/assets/javascripts/dashboard/current_tasks.js index 27046913a..7c2f1aea9 100644 --- a/app/assets/javascripts/dashboard/current_tasks.js +++ b/app/assets/javascripts/dashboard/current_tasks.js @@ -1,31 +1,43 @@ -/* global dropdownSelector I18n animateSpinner PerfectSb InfiniteScroll */ +/* global dropdownSelector animateSpinner PerfectSb InfiniteScroll */ /* eslint-disable no-param-reassign */ var DasboardCurrentTasksWidget = (function() { - var sortFilter = '.curent-tasks-filters .sort-filter'; - var viewFilter = '.curent-tasks-filters .view-filter'; - var projectFilter = '.curent-tasks-filters .project-filter'; - var experimentFilter = '.curent-tasks-filters .experiment-filter'; + var sortFilter = '.current-tasks-filters .sort-filter'; + var statusFilter = '.current-tasks-filters .view-filter'; + var projectFilter = '.current-tasks-filters .project-filter'; + var experimentFilter = '.current-tasks-filters .experiment-filter'; function generateTasksListHtml(json, container) { $.each(json.data, (i, task) => { var currentTaskItem = `
${task.project}/${task.experiment}
-
-
${task.name}
-
- ${I18n.t('dashboard.current_tasks.due_date', { date: task.due_date })} -
-
-
-
${task.state.text}
-
+
${task.name}
+
+ + ${task.due_date.text} + +
+
+ ${task.status_name}
`; $(container).append(currentTaskItem); }); } + function getDefaultStatusValues() { + // Select uncompleted status values + var values = []; + $(statusFilter).find('option').each(function(_, option) { + if ($(option).data('completionConsequence')) { + return false; + } + values.push(option.value); + return this; + }); + return values; + } + function initInfiniteScroll() { InfiniteScroll.init('.current-tasks-list', { url: $('.current-tasks-list').data('tasksListUrl'), @@ -36,7 +48,7 @@ var DasboardCurrentTasksWidget = (function() { params.project_id = dropdownSelector.getValues(projectFilter); params.experiment_id = dropdownSelector.getValues(experimentFilter); params.sort = dropdownSelector.getValues(sortFilter); - params.view = dropdownSelector.getValues(viewFilter); + params.statuses = dropdownSelector.getValues(statusFilter); params.query = $('.current-tasks-widget .task-search-field').val(); params.mode = $('.current-tasks-navbar .active').data('mode'); return params; @@ -47,8 +59,53 @@ var DasboardCurrentTasksWidget = (function() { function filtersEnabled() { return dropdownSelector.getValues(experimentFilter) || dropdownSelector.getValues(projectFilter) - || $('.current-tasks-widget .task-search-field').val().length > 0 - || dropdownSelector.getValues(viewFilter) !== 'uncompleted'; + || $('.current-tasks-widget .task-search-field').val().length > 0; + } + + function filterStateSave() { + var teamId = $('.current-tasks-filters').data('team-id'); + var filterState = { + sort: dropdownSelector.getValues(sortFilter), + statuses: dropdownSelector.getValues(statusFilter), + project_id: dropdownSelector.getData(projectFilter), + experiment_id: dropdownSelector.getData(experimentFilter), + mode: $('.current-tasks-navbar .active').data('mode') + }; + + if (filterState) { + localStorage.setItem('current_tasks_filters_per_team/' + teamId, JSON.stringify(filterState)); + } + } + + function filterStateLoad() { + var teamId = $('.current-tasks-filters').data('team-id'); + var filterState = localStorage.getItem('current_tasks_filters_per_team/' + teamId); + var parsedFilterState; + var allStatusValues = $.map($(statusFilter).find('option'), function(option) { + return option.value; + }); + + if (filterState) { + try { + parsedFilterState = JSON.parse(filterState); + dropdownSelector.selectValues(sortFilter, parsedFilterState.sort); + // Check if saved statuses are valid + if (parsedFilterState.statuses.every(status => allStatusValues.includes(status))) { + dropdownSelector.selectValues(statusFilter, parsedFilterState.statuses); + } else { + dropdownSelector.selectValues(statusFilter, getDefaultStatusValues()); + } + dropdownSelector.setData(projectFilter, parsedFilterState.project_id); + dropdownSelector.setData(experimentFilter, parsedFilterState.experiment_id); + // Select saved navbar state + $('.current-tasks-navbar .navbar-link').removeClass('active'); + $('.current-tasks-navbar').find(`[data-mode='${parsedFilterState.mode}']`).addClass('active'); + } catch (e) { + dropdownSelector.selectValues(statusFilter, getDefaultStatusValues()); + } + } else { + dropdownSelector.selectValues(statusFilter, getDefaultStatusValues()); + } } function loadCurrentTasksList(newList) { @@ -57,7 +114,7 @@ var DasboardCurrentTasksWidget = (function() { project_id: dropdownSelector.getValues(projectFilter), experiment_id: dropdownSelector.getValues(experimentFilter), sort: dropdownSelector.getValues(sortFilter), - view: dropdownSelector.getValues(viewFilter), + statuses: dropdownSelector.getValues(statusFilter), query: $('.current-tasks-widget .task-search-field').val(), mode: $('.current-tasks-navbar .active').data('mode') }; @@ -81,11 +138,12 @@ var DasboardCurrentTasksWidget = (function() { } function initFilters() { - $('.curent-tasks-filters .clear-button').click((e) => { + $('.current-tasks-filters .clear-button').click((e) => { e.stopPropagation(); e.preventDefault(); - dropdownSelector.selectValue(sortFilter, 'due_date'); - dropdownSelector.selectValue(viewFilter, 'uncompleted'); + + dropdownSelector.selectValues(sortFilter, 'due_date'); + dropdownSelector.selectValues(statusFilter, getDefaultStatusValues()); dropdownSelector.clearData(projectFilter); dropdownSelector.clearData(experimentFilter); }); @@ -98,12 +156,9 @@ var DasboardCurrentTasksWidget = (function() { disableSearch: true }); - dropdownSelector.init(viewFilter, { - noEmptyOption: true, - singleSelect: true, - closeOnSelect: true, + dropdownSelector.init(statusFilter, { selectAppearance: 'simple', - disableSearch: true + optionClass: 'checkbox-icon' }); dropdownSelector.init(projectFilter, { @@ -138,25 +193,27 @@ var DasboardCurrentTasksWidget = (function() { } }); - $('.curent-tasks-filters').click((e) => { + $('.current-tasks-filters').click((e) => { // Prevent filter window close e.stopPropagation(); e.preventDefault(); dropdownSelector.closeDropdown(sortFilter); - dropdownSelector.closeDropdown(viewFilter); + dropdownSelector.closeDropdown(statusFilter); dropdownSelector.closeDropdown(projectFilter); dropdownSelector.closeDropdown(experimentFilter); }); - $('.curent-tasks-filters .apply-filters').click((e) => { - $('.curent-tasks-filters').dropdown('toggle'); + $('.current-tasks-filters .apply-filters').click((e) => { + $('.current-tasks-filters').dropdown('toggle'); e.stopPropagation(); e.preventDefault(); loadCurrentTasksList(true); + filterStateSave(); }); $('.filter-container').on('hide.bs.dropdown', () => { loadCurrentTasksList(true); + filterStateSave(); $('.current-tasks-list').removeClass('disabled'); }); @@ -170,6 +227,7 @@ var DasboardCurrentTasksWidget = (function() { $(this).parent().find('.navbar-link').removeClass('active'); $(this).addClass('active'); loadCurrentTasksList(true); + filterStateSave(); }); } @@ -179,13 +237,13 @@ var DasboardCurrentTasksWidget = (function() { }); } - return { init: () => { if ($('.current-tasks-widget').length) { initNavbar(); initFilters(); initSearch(); + filterStateLoad(); loadCurrentTasksList(); initInfiniteScroll(); } diff --git a/app/assets/javascripts/global_activities/index.js b/app/assets/javascripts/global_activities/index.js index 0f4331703..4efc1e2e1 100644 --- a/app/assets/javascripts/global_activities/index.js +++ b/app/assets/javascripts/global_activities/index.js @@ -14,16 +14,6 @@ }); } - function initExpandCollapseButton() { - $('.ga-activities-list').on('hidden.bs.collapse', function(ev) { - $(ev.target.dataset.buttonLink) - .find('.fas').removeClass('fa-chevron-down').addClass('fa-chevron-right'); - }); - $('.ga-activities-list').on('shown.bs.collapse', function(ev) { - $(ev.target.dataset.buttonLink) - .find('.fas').removeClass('fa-chevron-right').addClass('fa-chevron-down'); - }); - } function initShowMoreButton() { var moreButton = $('.btn-more-activities'); moreButton.on('click', function(ev) { @@ -70,6 +60,5 @@ } initExpandCollapseAllButtons(); - initExpandCollapseButton(); initShowMoreButton(); }()); diff --git a/app/assets/javascripts/my_modules.js b/app/assets/javascripts/my_modules.js index bb2505b1a..cfb2fb20e 100644 --- a/app/assets/javascripts/my_modules.js +++ b/app/assets/javascripts/my_modules.js @@ -1,388 +1,425 @@ -/* global I18n dropdownSelector */ +/* global I18n dropdownSelector HelperModule animateSpinner */ /* eslint-disable no-use-before-define */ +(function() { + const STATUS_POLLING_INTERVAL = 5000; -function initTaskCollapseState() { - let taskView = '.my-modules-protocols-index'; - let taskSection = '.task-section-caret'; - let taskId = $(taskView).data('task-id'); + function initTaskCollapseState() { + let taskView = '.my-modules-protocols-index'; + let taskSection = '.task-section-caret'; + let taskId = $(taskView).data('task-id'); - function collapseStateSave() { - $(taskView).on('click', taskSection, function() { - let collapsed = $(this).attr('aria-expanded'); - let taskSectionType = $(this).attr('aria-controls'); + function collapseStateSave() { + $(taskView).on('click', taskSection, function() { + let collapsed = $(this).attr('aria-expanded'); + let taskSectionType = $(this).attr('aria-controls'); - if (collapsed === 'true') { - localStorage.setItem('task_section_collapsed/' + taskId + '/' + taskSectionType, collapsed); - } else { - localStorage.removeItem('task_section_collapsed/' + taskId + '/' + taskSectionType); - } - }); - } - - function collapseStateLoad() { - $(taskSection).each(function() { - let taskSectionType = $(this).attr('aria-controls'); - var collapsed = localStorage.getItem('task_section_collapsed/' + taskId + '/' + taskSectionType); - - if (JSON.parse(collapsed)) { - $('#' + taskSectionType).collapse('hide'); - } - $(this).closest('.task-section').removeClass('hidden'); - }); - } - - collapseStateSave(); - collapseStateLoad(); -} - -function updateStartDate() { - let updateUrl = $('#startDateContainer').data('update-url'); - let val = $('#calendarStartDate').val(); - $.ajax({ - url: updateUrl, - type: 'PATCH', - dataType: 'json', - data: { my_module: { started_on: val } }, - success: function(result) { - $('#startDateLabelContainer').html(result.start_date_label); - } - }); -} - -// Bind ajax for editing due dates -function initStartDatePicker() { - $('#calendarStartDate').on('dp.change', function() { - updateStartDate(); - }); -} - -function updateDueDate() { - let updateUrl = $('#dueDateContainer').data('update-url'); - let val = $('#calendarDueDate').val(); - $.ajax({ - url: updateUrl, - type: 'PATCH', - dataType: 'json', - data: { my_module: { due_date: val } }, - success: function(result) { - $('#dueDateLabelContainer').html(result.due_date_label); - } - }); -} - -// Bind ajax for editing due dates -function initDueDatePicker() { - $('#calendarDueDate').on('dp.change', function() { - updateDueDate(); - }); -} - - -// Bind ajax for editing tags -function bindEditTagsAjax() { - var manageTagsModal = null; - var manageTagsModalBody = null; - - // Initialize reloading of manage tags modal content after posting new - // tag. - function initAddTagForm() { - manageTagsModalBody.find('.add-tag-form') - .submit(function() { - var selectOptions = manageTagsModalBody.find('#new_my_module_tag .dropdown-menu li').length; - if (selectOptions === 0 && this.id === 'new_my_module_tag') return false; - return true; - }) - .on('ajax:success', function(e, data) { - var newTag; - initTagsModalBody(data); - newTag = $('#manage-module-tags-modal .list-group-item').last(); - dropdownSelector.addValue('#module-tags-selector', { - value: newTag.data('tag-id'), - label: newTag.data('name'), - params: { - color: newTag.data('color') - } - }, true); - }); - } - - // Initialize edit tag & remove tag functionality from my_module links. - function initTagRowLinks() { - manageTagsModalBody.find('.edit-tag-link') - .on('click', function() { - var $this = $(this); - var li = $this.parents('li.list-group-item'); - var editDiv = $(li.find('div.tag-edit')); - - // Revert all rows to their original states - manageTagsModalBody.find('li.list-group-item').each(function() { - var li2 = $(this); - li2.css('background-color', li2.data('color')); - li2.find('.edit-tag-form').clearFormErrors(); - li2.find('input[type=text]').val(li2.data('name')); - }); - - // Hide all other edit divs, show all show divs - manageTagsModalBody.find('div.tag-edit').hide(); - manageTagsModalBody.find('div.tag-show').show(); - - editDiv.find('input[type=text]').val(li.data('name')); - editDiv.find('.edit-tag-color').colorselector('setColor', li.data('color')); - - li.find('div.tag-show').hide(); - editDiv.show(); - }); - manageTagsModalBody.find('div.tag-edit .dropdown-colorselector > .dropdown-menu li a') - .on('click', function() { - // Change background of the
  • - var $this = $(this); - var li = $this.parents('li.list-group-item'); - li.css('background-color', $this.data('value')); - }); - manageTagsModalBody.find('.remove-tag-link') - .on('ajax:success', function(e, data) { - dropdownSelector.removeValue('#module-tags-selector', this.dataset.tagId, '', true); - initTagsModalBody(data); - }); - manageTagsModalBody.find('.delete-tag-form') - .on('ajax:success', function(e, data) { - dropdownSelector.removeValue('#module-tags-selector', this.dataset.tagId, '', true); - initTagsModalBody(data); - }); - manageTagsModalBody.find('.edit-tag-form') - .on('ajax:success', function(e, data) { - var newTag; - initTagsModalBody(data); - dropdownSelector.removeValue('#module-tags-selector', this.dataset.tagId, '', true); - newTag = $('#manage-module-tags-modal .list-group-item[data-tag-id=' + this.dataset.tagId + ']'); - dropdownSelector.addValue('#module-tags-selector', { - value: newTag.data('tag-id'), - label: newTag.data('name'), - params: { - color: newTag.data('color') - } - }, true); - }) - .on('ajax:error', function(e, data) { - $(this).renderFormErrors('tag', data.responseJSON); - }); - manageTagsModalBody.find('.cancel-tag-link') - .on('click', function() { - var $this = $(this); - var li = $this.parents('li.list-group-item'); - - li.css('background-color', li.data('color')); - li.find('.edit-tag-form').clearFormErrors(); - - li.find('div.tag-edit').hide(); - li.find('div.tag-show').show(); - }); - } - - // Initialize ajax listeners and elements style on modal body. This - // function must be called when modal body is changed. - function initTagsModalBody(data) { - manageTagsModalBody.html(data.html); - manageTagsModalBody.find('.selectpicker').selectpicker(); - initAddTagForm(); - initTagRowLinks(); - } - - manageTagsModal = $('#manage-module-tags-modal'); - manageTagsModalBody = manageTagsModal.find('.modal-body'); - - // Reload tags HTML element when modal is closed - manageTagsModal.on('hide.bs.modal', function() { - var tagsEl = $('#module-tags'); - - // Load HTML - $.ajax({ - url: tagsEl.attr('data-module-tags-url'), - type: 'GET', - dataType: 'json', - success: function(data) { - var newOptions = $(data.html_module_header).find('option'); - $('#module-tags-selector').find('option').remove(); - $(newOptions).appendTo('#module-tags-selector').change(); - }, - error: function() { - // TODO - } - }); - }); - - // Remove modal content when modal window is closed. - manageTagsModal.on('hidden.bs.modal', function() { - manageTagsModalBody.html(''); - }); - // initialize my_module tab remote loading - $('.edit-tags-link') - .on('ajax:before', function() { - manageTagsModal.modal('show'); - }) - .on('ajax:success', function(e, data) { - $('#manage-module-tags-modal-module').text(data.my_module.name); - initTagsModalBody(data); - }); -} - -// Sets callback for completing/uncompleting task -function applyTaskCompletedCallBack() { - $("[data-action='complete-task'], [data-action='uncomplete-task']") - .on('click', function() { - var button = $(this); - $.ajax({ - url: button.data('link-url'), - type: 'POST', - dataType: 'json', - success: function(data) { - if (data.completed === true) { - button.attr('data-action', 'uncomplete-task'); - button.find('.btn') - .removeClass('btn-primary').addClass('btn-default'); - } else { - button.attr('data-action', 'complete-task'); - button.find('.btn') - .removeClass('btn-default').addClass('btn-primary'); - } - $('#dueDateContainer').html(data.module_header_due_date); - initDueDatePicker(); - $('.task-state-label').html(data.module_state_label); - button.find('button').replaceWith(data.new_btn); - }, - error: function() { + if (collapsed === 'true') { + localStorage.setItem('task_section_collapsed/' + taskId + '/' + taskSectionType, collapsed); + } else { + localStorage.removeItem('task_section_collapsed/' + taskId + '/' + taskSectionType); } }); - }); -} + } -function initTagsSelector() { - var myModuleTagsSelector = '#module-tags-selector'; + function collapseStateLoad() { + $(taskSection).each(function() { + let taskSectionType = $(this).attr('aria-controls'); + var collapsed = localStorage.getItem('task_section_collapsed/' + taskId + '/' + taskSectionType); - dropdownSelector.init(myModuleTagsSelector, { - closeOnSelect: true, - tagClass: 'my-module-white-tags', - tagStyle: (data) => { - return `background: ${data.params.color}`; - }, - customDropdownIcon: () => { - return ''; - }, - optionLabel: (data) => { - if (data.value > 0) { - return ` - ${data.label}`; + if (JSON.parse(collapsed)) { + $('#' + taskSectionType).collapse('hide'); + } + $(this).closest('.task-section').removeClass('hidden'); + }); + } + + collapseStateSave(); + collapseStateLoad(); + } + + function updateStartDate() { + let updateUrl = $('#startDateContainer').data('update-url'); + let val = $('#calendarStartDate').val(); + $.ajax({ + url: updateUrl, + type: 'PATCH', + dataType: 'json', + data: { my_module: { started_on: val } }, + success: function(result) { + $('#startDateLabelContainer').html(result.start_date_label); + }, + error: function(response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } } - return ` - ${data.label + ' '} - (${I18n.t('my_modules.details.create_new_tag')})`; - }, - onOpen: function() { - $('.select-container .edit-button-container').removeClass('hidden'); - }, - onClose: function() { - $('.select-container .edit-button-container').addClass('hidden'); - }, - onSelect: function() { - var selectElement = $(myModuleTagsSelector); - var lastTag = selectElement.next().find('.ds-tags').last(); - var lastTagId = lastTag.find('.tag-label').data('ds-tag-id'); - var newTag; + }); + } - if (lastTagId > 0) { - newTag = { my_module_tag: { tag_id: lastTagId } }; - $.post(selectElement.data('update-module-tags-url'), newTag) - .fail(function() { - dropdownSelector.removeValue(myModuleTagsSelector, lastTagId, '', true); - }); - } else { - newTag = { - tag: { - name: lastTag.find('.tag-label').html(), - project_id: selectElement.data('project-id'), - color: null - }, - my_module_id: selectElement.data('module-id'), - simple_creation: true - }; - $.post(selectElement.data('tags-create-url'), newTag, function(result) { - dropdownSelector.removeValue(myModuleTagsSelector, 0, '', true); - dropdownSelector.addValue(myModuleTagsSelector, { - value: result.tag.id, - label: result.tag.name, + // Bind ajax for editing due dates + function initStartDatePicker() { + $('#calendarStartDate').on('dp.change', function() { + updateStartDate(); + }); + } + + function updateDueDate() { + let updateUrl = $('#dueDateContainer').data('update-url'); + let val = $('#calendarDueDate').val(); + $.ajax({ + url: updateUrl, + type: 'PATCH', + dataType: 'json', + data: { my_module: { due_date: val } }, + success: function(result) { + $('#dueDateLabelContainer').html(result.due_date_label); + }, + error: function(response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } + } + }); + } + + // Bind ajax for editing due dates + function initDueDatePicker() { + $('#calendarDueDate').on('dp.change', function() { + updateDueDate(); + }); + } + + + // Bind ajax for editing tags + function bindEditTagsAjax() { + var manageTagsModal = null; + var manageTagsModalBody = null; + + // Initialize reloading of manage tags modal content after posting new + // tag. + function initAddTagForm() { + manageTagsModalBody.find('.add-tag-form') + .submit(function() { + var selectOptions = manageTagsModalBody.find('#new_my_module_tag .dropdown-menu li').length; + if (selectOptions === 0 && this.id === 'new_my_module_tag') return false; + return true; + }) + .on('ajax:success', function(e, data) { + var newTag; + initTagsModalBody(data); + newTag = $('#manage-module-tags-modal .list-group-item').last(); + dropdownSelector.addValue('#module-tags-selector', { + value: newTag.data('tag-id'), + label: newTag.data('name'), params: { - color: result.tag.color + color: newTag.data('color') } }, true); }); - } - }, - onUnSelect: (id) => { - $.post(`${$(myModuleTagsSelector).data('update-module-tags-url')}/${id}/destroy_by_tag_id`); - dropdownSelector.closeDropdown(myModuleTagsSelector); } - }).getContainer(myModuleTagsSelector).addClass('my-module-tags-container'); -} -function initAssignedUsersSelector() { - var manageUsersModal = $('#manage-module-users-modal'); - var manageUsersModalBody = manageUsersModal.find('.modal-body'); + // Initialize edit tag & remove tag functionality from my_module links. + function initTagRowLinks() { + manageTagsModalBody.find('.edit-tag-link') + .on('click', function() { + var $this = $(this); + var li = $this.parents('li.list-group-item'); + var editDiv = $(li.find('div.tag-edit')); - // Initialize users editing modal remote loading - function initUsersEditLink() { - $('.task-details').on('ajax:success', '.manage-users-link', function(e, data) { - manageUsersModal.modal('show'); - manageUsersModal.find('#manage-module-users-modal-module').text(data.my_module.name); + // Revert all rows to their original states + manageTagsModalBody.find('li.list-group-item').each(function() { + var li2 = $(this); + li2.css('background-color', li2.data('color')); + li2.find('.edit-tag-form').clearFormErrors(); + li2.find('input[type=text]').val(li2.data('name')); + }); + + // Hide all other edit divs, show all show divs + manageTagsModalBody.find('div.tag-edit').hide(); + manageTagsModalBody.find('div.tag-show').show(); + + editDiv.find('input[type=text]').val(li.data('name')); + editDiv.find('.edit-tag-color').colorselector('setColor', li.data('color')); + + li.find('div.tag-show').hide(); + editDiv.show(); + }); + manageTagsModalBody.find('div.tag-edit .dropdown-colorselector > .dropdown-menu li a') + .on('click', function() { + // Change background of the
  • + var $this = $(this); + var li = $this.parents('li.list-group-item'); + li.css('background-color', $this.data('value')); + }); + manageTagsModalBody.find('.remove-tag-link') + .on('ajax:success', function(e, data) { + dropdownSelector.removeValue('#module-tags-selector', this.dataset.tagId, '', true); + initTagsModalBody(data); + }); + manageTagsModalBody.find('.delete-tag-form') + .on('ajax:success', function(e, data) { + dropdownSelector.removeValue('#module-tags-selector', this.dataset.tagId, '', true); + initTagsModalBody(data); + }); + manageTagsModalBody.find('.edit-tag-form') + .on('ajax:success', function(e, data) { + var newTag; + initTagsModalBody(data); + dropdownSelector.removeValue('#module-tags-selector', this.dataset.tagId, '', true); + newTag = $('#manage-module-tags-modal .list-group-item[data-tag-id=' + this.dataset.tagId + ']'); + dropdownSelector.addValue('#module-tags-selector', { + value: newTag.data('tag-id'), + label: newTag.data('name'), + params: { + color: newTag.data('color') + } + }, true); + }) + .on('ajax:error', function(e, data) { + $(this).renderFormErrors('tag', data.responseJSON); + }); + manageTagsModalBody.find('.cancel-tag-link') + .on('click', function() { + var $this = $(this); + var li = $this.parents('li.list-group-item'); + + li.css('background-color', li.data('color')); + li.find('.edit-tag-form').clearFormErrors(); + + li.find('div.tag-edit').hide(); + li.find('div.tag-show').show(); + }); + } + + // Initialize ajax listeners and elements style on modal body. This + // function must be called when modal body is changed. + function initTagsModalBody(data) { + manageTagsModalBody.html(data.html); + manageTagsModalBody.find('.selectpicker').selectpicker(); + initAddTagForm(); + initTagRowLinks(); + } + + manageTagsModal = $('#manage-module-tags-modal'); + manageTagsModalBody = manageTagsModal.find('.modal-body'); + + // Reload tags HTML element when modal is closed + manageTagsModal.on('hide.bs.modal', function() { + var tagsEl = $('#module-tags'); + + // Load HTML + $.ajax({ + url: tagsEl.attr('data-module-tags-url'), + type: 'GET', + dataType: 'json', + success: function(data) { + var newOptions = $(data.html_module_header).find('option'); + $('#module-tags-selector').find('option').remove(); + $(newOptions).appendTo('#module-tags-selector').change(); + }, + error: function() { + // TODO + } + }); + }); + + // Remove modal content when modal window is closed. + manageTagsModal.on('hidden.bs.modal', function() { + manageTagsModalBody.html(''); + }); + // initialize my_module tab remote loading + $('.edit-tags-link') + .on('ajax:before', function() { + manageTagsModal.modal('show'); + }) + .on('ajax:success', function(e, data) { + $('#manage-module-tags-modal-module').text(data.my_module.name); + initTagsModalBody(data); + }); + } + + function checkStatusState() { + $.getJSON($('.status-flow-dropdown').data('status-check-url'), (statusData) => { + if (statusData.status_changing) { + setTimeout(() => { checkStatusState(); }, STATUS_POLLING_INTERVAL); + } else { + location.reload(); + } + }); + } + + function applyTaskStatusChangeCallBack() { + if ($('.status-flow-dropdown').data('status-changing')) { + setTimeout(() => { checkStatusState(); }, STATUS_POLLING_INTERVAL); + } + $('.task-flows').on('click', '#dropdownTaskFlowList > li[data-state-id]', function() { + var list = $('#dropdownTaskFlowList'); + var item = $(this); + animateSpinner(); + $.ajax({ + url: list.data('link-url'), + beforeSend: function(e, ajaxSettings) { + if (item.data('beforeSend') instanceof Function) { + return item.data('beforeSend')(item, ajaxSettings) + } + return true + }, + type: 'PATCH', + data: { my_module: { status_id: item.data('state-id') } }, + error: function(e) { + animateSpinner(null, false); + if (e.status === 403) { + HelperModule.flashAlertMsg(I18n.t('my_module_statuses.update_status.error.no_permission'), 'danger'); + } else if (e.status === 422) { + HelperModule.flashAlertMsg(e.responseJSON.errors, 'danger'); + } else { + HelperModule.flashAlertMsg('error', 'danger'); + } + } + }); + }); + } + + function initTagsSelector() { + var myModuleTagsSelector = '#module-tags-selector'; + + dropdownSelector.init(myModuleTagsSelector, { + closeOnSelect: true, + tagClass: 'my-module-white-tags', + tagStyle: (data) => { + return `background: ${data.params.color}`; + }, + customDropdownIcon: () => { + return ''; + }, + optionLabel: (data) => { + if (data.value > 0) { + return ` + ${data.label}`; + } + return ` + ${data.label + ' '} + (${I18n.t('my_modules.details.create_new_tag')})`; + }, + onOpen: function() { + $('.select-container .edit-button-container').removeClass('hidden'); + }, + onClose: function() { + $('.select-container .edit-button-container').addClass('hidden'); + }, + onSelect: function() { + var selectElement = $(myModuleTagsSelector); + var lastTag = selectElement.next().find('.ds-tags').last(); + var lastTagId = lastTag.find('.tag-label').data('ds-tag-id'); + var newTag; + + if (lastTagId > 0) { + newTag = { my_module_tag: { tag_id: lastTagId } }; + $.post(selectElement.data('update-module-tags-url'), newTag) + .fail(function(response) { + dropdownSelector.removeValue(myModuleTagsSelector, lastTagId, '', true); + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } + }); + } else { + newTag = { + tag: { + name: lastTag.find('.tag-label').html(), + project_id: selectElement.data('project-id'), + color: null + }, + my_module_id: selectElement.data('module-id'), + simple_creation: true + }; + $.post(selectElement.data('tags-create-url'), newTag, function(result) { + dropdownSelector.removeValue(myModuleTagsSelector, 0, '', true); + dropdownSelector.addValue(myModuleTagsSelector, { + value: result.tag.id, + label: result.tag.name, + params: { + color: result.tag.color + } + }, true); + }).fail(function() { + dropdownSelector.removeValue(myModuleTagsSelector, lastTagId, '', true); + }); + } + }, + onUnSelect: (id) => { + $.post(`${$(myModuleTagsSelector).data('update-module-tags-url')}/${id}/destroy_by_tag_id`) + .success(function() { + dropdownSelector.closeDropdown(myModuleTagsSelector); + }) + .fail(function(r) { + if (r.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } + }); + } + }).getContainer(myModuleTagsSelector).addClass('my-module-tags-container'); + } + + function initAssignedUsersSelector() { + var manageUsersModal = $('#manage-module-users-modal'); + var manageUsersModalBody = manageUsersModal.find('.modal-body'); + + // Initialize users editing modal remote loading + function initUsersEditLink() { + $('.task-details').on('ajax:success', '.manage-users-link', function(e, data) { + manageUsersModal.modal('show'); + manageUsersModal.find('#manage-module-users-modal-module').text(data.my_module.name); + initUsersModalBody(data); + }); + } + + // Initialize ajax listeners and elements style on modal body. + // This function must be called when modal body is changed. + function initUsersModalBody(data) { + manageUsersModalBody.html(data.html); + manageUsersModalBody.find('.selectpicker').selectpicker(); + } + + // Initialize reloading manage user modal content after posting new user + manageUsersModalBody.on('ajax:success', '.add-user-form', function(e, data) { initUsersModalBody(data); }); - } - // Initialize ajax listeners and elements style on modal body. - // This function must be called when modal body is changed. - function initUsersModalBody(data) { - manageUsersModalBody.html(data.html); - manageUsersModalBody.find('.selectpicker').selectpicker(); - } - - // Initialize reloading manage user modal content after posting new user - manageUsersModalBody.on('ajax:success', '.add-user-form', function(e, data) { - initUsersModalBody(data); - }); - - // Initialize remove user from my_module links - manageUsersModalBody.on('ajax:success', '.remove-user-link', function(e, data) { - initUsersModalBody(data); - }); - - // Reload users HTML element when modal is closed - manageUsersModal.on('hide.bs.modal', function() { - var usersEl = $('.task-assigned-users'); - // Load HTML to refresh users - $.ajax({ - url: usersEl.attr('data-module-users-url'), - type: 'GET', - dataType: 'json', - success: function(data) { - $('.task-assigned-users').replaceWith(data.html); - }, - error: function() { - // TODO - } + // Initialize remove user from my_module links + manageUsersModalBody.on('ajax:success', '.remove-user-link', function(e, data) { + initUsersModalBody(data); }); - }); - // Remove users modal content when modal window is closed. - manageUsersModal.on('hidden.bs.modal', function() { - manageUsersModalBody.html(''); - }); + // Reload users HTML element when modal is closed + manageUsersModal.on('hide.bs.modal', function() { + var usersEl = $('.task-assigned-users'); + // Load HTML to refresh users + $.ajax({ + url: usersEl.attr('data-module-users-url'), + type: 'GET', + dataType: 'json', + success: function(data) { + $('.task-assigned-users').replaceWith(data.html); + }, + error: function() { + // TODO + } + }); + }); - initUsersEditLink(); -} + // Remove users modal content when modal window is closed. + manageUsersModal.on('hidden.bs.modal', function() { + manageUsersModalBody.html(''); + }); -initTaskCollapseState(); -applyTaskCompletedCallBack(); -initTagsSelector(); -bindEditTagsAjax(); -initStartDatePicker(); -initDueDatePicker(); -initAssignedUsersSelector(); + initUsersEditLink(); + } + + initTaskCollapseState(); + applyTaskStatusChangeCallBack(); + initTagsSelector(); + bindEditTagsAjax(); + initStartDatePicker(); + initDueDatePicker(); + initAssignedUsersSelector(); +}()); diff --git a/app/assets/javascripts/my_modules/protocols.js b/app/assets/javascripts/my_modules/protocols.js index aeadf3a6e..5edc8cdab 100644 --- a/app/assets/javascripts/my_modules/protocols.js +++ b/app/assets/javascripts/my_modules/protocols.js @@ -11,16 +11,24 @@ var selectedRow = null; function initEditMyModuleDescription() { var viewObject = $('#my_module_description_view'); - viewObject.on('click', function() { + viewObject.on('click', function(e) { + if ($(e.target).hasClass('record-info-link')) return; TinyMCE.init('#my_module_description_textarea'); + }).on('click', 'a', function(e) { + if ($(this).hasClass('record-info-link')) return; + e.stopPropagation(); }); TinyMCE.initIfHasDraft(viewObject); } function initEditProtocolDescription() { var viewObject = $('#protocol_description_view'); - viewObject.on('click', function() { + viewObject.on('click', function(e) { + if ($(e.target).hasClass('record-info-link')) return; TinyMCE.init('#protocol_description_textarea', refreshProtocolStatusBar); + }).on('click', 'a', function(e) { + if ($(this).hasClass('record-info-link')) return; + e.stopPropagation(); }); TinyMCE.initIfHasDraft(viewObject); } @@ -361,11 +369,13 @@ function loadFromRepository() { // Simply reload page location.reload(); }, - error: function(ev) { - // Display error message in alert() - alert(ev.responseJSON.message); + error: function(response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } else { + alert(response.responseJSON.message); + } - // Hide modal modal.modal('hide'); } }); diff --git a/app/assets/javascripts/my_modules/repositories.js b/app/assets/javascripts/my_modules/repositories.js index 467b6be52..897553a4d 100644 --- a/app/assets/javascripts/my_modules/repositories.js +++ b/app/assets/javascripts/my_modules/repositories.js @@ -453,7 +453,8 @@ var MyModuleRepositories = (function() { FULL_VIEW_MODAL.on('show.bs.modal', function() { FULL_VIEW_MODAL.find('.table-container').empty(); - FULL_VIEW_MODAL.find('.repository-name').empty(); + FULL_VIEW_MODAL.find('.repository-title').empty(); + FULL_VIEW_MODAL.find('.repository-version').empty(); updateFullViewRowsCount(''); }); } @@ -518,29 +519,31 @@ var MyModuleRepositories = (function() { function updateFullViewRowsCount(value) { FULL_VIEW_MODAL.data('rows-count', value); - FULL_VIEW_MODAL.find('.repository-name').attr('data-rows-count', value); + FULL_VIEW_MODAL.find('.repository-version').attr('data-rows-count', value); } function renderFullViewRepositoryName(name, snapshotDate, assignMode) { var title; - var repositoryName = name || FULL_VIEW_MODAL.find('.repository-name').data('repository-name'); + var version; + var repositoryName = name || FULL_VIEW_MODAL.find('.repository-title').data('repository-name'); if (assignMode) { title = I18n.t('my_modules.repository.full_view.assign_modal_header', { repository_name: repositoryName }); + version = ''; } else if (snapshotDate) { - title = I18n.t('my_modules.repository.full_view.modal_snapshot_header', { - repository_name: repositoryName, + title = repositoryName; + version = I18n.t('my_modules.repository.full_view.modal_snapshot_header', { snaphot_date: snapshotDate }); } else { - title = I18n.t('my_modules.repository.full_view.modal_live_header', { - repository_name: repositoryName - }); + title = repositoryName; + version = I18n.t('my_modules.repository.full_view.modal_live_header'); } - FULL_VIEW_MODAL.find('.repository-name').data('repository-name', repositoryName); - FULL_VIEW_MODAL.find('.repository-name').html(title); + FULL_VIEW_MODAL.find('.repository-title').data('repository-name', repositoryName); + FULL_VIEW_MODAL.find('.repository-title').html(title); + FULL_VIEW_MODAL.find('.repository-version').html(version); } function initRepoistoryAssignView() { @@ -635,9 +638,13 @@ var MyModuleRepositories = (function() { updateFullViewRowsCount(data.rows_count); renderFullViewAssignButtons(); }, - error: function(data) { + error: function(response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } else { + HelperModule.flashAlertMsg(response.responseJSON.flash, 'danger'); + } UPDATE_REPOSITORY_MODAL.modal('hide'); - HelperModule.flashAlertMsg(data.responseJSON.flash, 'danger'); SELECTED_ROWS = {}; FULL_VIEW_TABLE.ajax.reload(null, false); } diff --git a/app/assets/javascripts/my_modules/results.js b/app/assets/javascripts/my_modules/results.js index da0cfa181..0483d0a49 100644 --- a/app/assets/javascripts/my_modules/results.js +++ b/app/assets/javascripts/my_modules/results.js @@ -47,17 +47,6 @@ }); } - function applyCollapseLinkCallBack() { - $('.panel-collapse') - .on('shown.bs.collapse hidden.bs.collapse', function() { - var collapseIcon = $(this).closest('.panel').find('.collapse-result-icon'); - var collapsed = $(this).closest('.panel').find('.result-panel-collapse-link').hasClass('collapsed'); - // Toggle collapse button - collapseIcon.toggleClass('fa-caret-up', !collapsed); - collapseIcon.toggleClass('fa-caret-down', collapsed); - }); - } - // Toggle editing buttons function toggleResultEditButtons(show) { if (show) { @@ -80,10 +69,6 @@ // Expand all results function expandAllResults() { $('.result .panel-collapse').collapse('show'); - $(document).find('span.collapse-result-icon').each(function() { - $(this).addClass('fa-caret-up'); - $(this).removeClass('fa-caret-down'); - }); $(document).find('div.step-result-hot-table').each(function() { renderTable(this); }); @@ -91,10 +76,6 @@ function expandResult(result) { $('.panel-collapse', result).collapse('show'); - $(result).find('span.collapse-result-icon').each(function() { - $(this).addClass('fa-caret-up'); - $(this).removeClass('fa-caret-down'); - }); renderTable($(result).find('div.step-result-hot-table')); animateSpinner(null, false); } @@ -135,7 +116,6 @@ $.each($('#results').find('.result'), function() { initFormSubmitLinks($(this)); ResultAssets.applyEditResultAssetCallback(); - applyCollapseLinkCallBack(); applyCreateWopiFileCallback(); toggleResultEditButtons(true); FilePreviewModal.init(); @@ -208,15 +188,11 @@ function init() { initHandsOnTables($(document)); expandAllResults(); - applyCollapseLinkCallBack(); applyCreateWopiFileCallback(); $(function() { $('#results-collapse-btn').click(function() { $('.result .panel-collapse').collapse('hide'); - $(document).find('span.collapse-result-icon') - .addClass('fa-caret-down') - .removeClass('fa-caret-square-up'); }); $('#results-expand-btn').click(expandAllResults); @@ -233,7 +209,6 @@ let publicAPI = Object.freeze({ init: init, initHandsOnTables: initHandsOnTables, - applyCollapseLinkCallBack: applyCollapseLinkCallBack, toggleResultEditButtons: toggleResultEditButtons, expandResult: expandResult, processResult: processResult, diff --git a/app/assets/javascripts/my_modules/status_flow.js b/app/assets/javascripts/my_modules/status_flow.js new file mode 100644 index 000000000..bc283d47b --- /dev/null +++ b/app/assets/javascripts/my_modules/status_flow.js @@ -0,0 +1,16 @@ +/* global animateSpinner */ + +(function() { + $('.task-flows').on('click', '#viewTaskFlow', function() { + $('#statusFlowModal').modal('show'); + }); + + $('#statusFlowModal').on('show.bs.modal', function() { + var $modalBody = $(this).find('.modal-body'); + animateSpinner($modalBody); + $.get($(this).data('status-flow-url'), function(result) { + animateSpinner($modalBody, false); + $modalBody.html(result.html); + }); + }); +}()); diff --git a/app/assets/javascripts/protocols/external_protocols_tab.js b/app/assets/javascripts/protocols/external_protocols_tab.js index 239658666..5fcac32bf 100644 --- a/app/assets/javascripts/protocols/external_protocols_tab.js +++ b/app/assets/javascripts/protocols/external_protocols_tab.js @@ -176,10 +176,6 @@ function expandAllSteps() { $(document).find("[data-role='step-hot-table']").each(function() { renderTable($(this)); }); - $(document).find('span.collapse-step-icon').each(function() { - $(this).addClass('fa-caret-square-up'); - $(this).removeClass('fa-caret-square-down'); - }); } function handleFormSubmit(modal) { diff --git a/app/assets/javascripts/protocols/header.js b/app/assets/javascripts/protocols/header.js index b1a1683d8..13f678e7d 100644 --- a/app/assets/javascripts/protocols/header.js +++ b/app/assets/javascripts/protocols/header.js @@ -22,8 +22,12 @@ var ProtocolRepositoryHeader = (function() { function initEditDescription() { var viewObject = $('#protocol_description_view'); - viewObject.on('click', function() { + viewObject.on('click', function(e) { + if ($(e.target).hasClass('record-info-link')) return; TinyMCE.init('#protocol_description_textarea'); + }).on('click', 'a', function(e) { + if ($(this).hasClass('record-info-link')) return; + e.stopPropagation(); }); TinyMCE.initIfHasDraft(viewObject); } diff --git a/app/assets/javascripts/protocols/index.js b/app/assets/javascripts/protocols/index.js index 92653a5cd..dcd29c0c1 100644 --- a/app/assets/javascripts/protocols/index.js +++ b/app/assets/javascripts/protocols/index.js @@ -439,79 +439,78 @@ function updateButtons() { var archiveBtn = $("[data-action='archive']"); var restoreBtn = $("[data-action='restore']"); var exportBtn = $("[data-action='export']"); + var row = $("tr[data-row-id='" + rowsSelected[0] + "']"); + var rows = []; - if (rowsSelected.length == 1) { + if (rowsSelected.length === 1) { // 1 ROW SELECTED - var row = $("tr[data-row-id='" + rowsSelected[0] + "']"); - - if (row.is("[data-can-edit]")) { - editBtn.removeAttr("disabled"); - editBtn.off("click").on("click", function() { editSelectedProtocol(); }); + if (row.is('[data-can-edit]')) { + editBtn.removeClass('disabled hidden'); + editBtn.off('click').on('click', function() { editSelectedProtocol(); }); } else { - editBtn.attr("disabled", "disabled"); - editBtn.off("click"); + editBtn.removeClass('hidden').addClass('disabled'); + editBtn.off('click'); } - if (row.is("[data-can-clone]")) { - cloneBtn.removeAttr("disabled"); - cloneBtn.off("click").on("click", function() { cloneSelectedProtocol(); }); + if (row.is('[data-can-clone]')) { + cloneBtn.removeClass('disabled hidden'); + cloneBtn.off('click').on('click', function() { cloneSelectedProtocol(); }); } else { - cloneBtn.attr("disabled", "disabled"); - cloneBtn.off("click"); + cloneBtn.removeClass('hidden').addClass('disabled'); + cloneBtn.off('click'); } - if (row.is("[data-can-make-private]")) { - makePrivateBtn.removeAttr("disabled"); - makePrivateBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + if (row.is('[data-can-make-private]')) { + makePrivateBtn.removeClass('disabled hidden'); + makePrivateBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - makePrivateBtn.attr("disabled", "disabled"); - makePrivateBtn.off("click"); + makePrivateBtn.removeClass('hidden').addClass('disabled'); + makePrivateBtn.off('click'); } - if (row.is("[data-can-publish]")) { - publishBtn.removeAttr("disabled"); - publishBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + if (row.is('[data-can-publish]')) { + publishBtn.removeClass('disabled hidden'); + publishBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - publishBtn.attr("disabled", "disabled"); - publishBtn.off("click"); + publishBtn.removeClass('hidden').addClass('disabled'); + publishBtn.off('click'); } - if (row.is("[data-can-archive]")) { - archiveBtn.removeAttr("disabled"); - archiveBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + if (row.is('[data-can-archive]')) { + archiveBtn.removeClass('disabled hidden'); + archiveBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - archiveBtn.attr("disabled", "disabled"); - archiveBtn.off("click"); + archiveBtn.removeClass('hidden').addClass('disabled'); + archiveBtn.off('click'); } - if (row.is("[data-can-restore]")) { - restoreBtn.removeAttr("disabled"); - restoreBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + if (row.is('[data-can-restore]')) { + restoreBtn.removeClass('disabled hidden'); + restoreBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - restoreBtn.attr("disabled", "disabled"); - restoreBtn.off("click"); + restoreBtn.removeClass('hidden').addClass('disabled'); + restoreBtn.off('click'); } - if (row.is("[data-can-export]")) { - exportBtn.removeAttr("disabled"); - exportBtn.off("click").on("click", function() { exportProtocols(rowsSelected); }); + if (row.is('[data-can-export]')) { + exportBtn.removeClass('disabled hidden'); + exportBtn.off('click').on('click', function() { exportProtocols(rowsSelected); }); } else { - exportBtn.attr("disabled", "disabled"); - exportBtn.off("click"); + exportBtn.removeClass('hidden').addClass('disabled'); + exportBtn.off('click'); } } else if (rowsSelected.length === 0) { // 0 ROWS SELECTED - editBtn.attr("disabled", "disabled"); - editBtn.off("click"); - cloneBtn.attr("disabled", "disabled"); - cloneBtn.off("click"); - makePrivateBtn.attr("disabled", "disabled"); - makePrivateBtn.off("click"); - publishBtn.attr("disabled", "disabled"); - publishBtn.off("click"); - archiveBtn.attr("disabled", "disabled"); - archiveBtn.off("click"); - restoreBtn.attr("disabled", "disabled"); - restoreBtn.off("click"); - exportBtn.attr("disabled", "disabled"); - exportBtn.off("click"); + editBtn.addClass('disabled hidden'); + editBtn.off('click'); + cloneBtn.addClass('disabled hidden'); + cloneBtn.off('click'); + makePrivateBtn.addClass('disabled hidden'); + makePrivateBtn.off('click'); + publishBtn.addClass('disabled hidden'); + publishBtn.off('click'); + archiveBtn.addClass('disabled hidden'); + archiveBtn.off('click'); + restoreBtn.addClass('disabled hidden'); + restoreBtn.off('click'); + exportBtn.addClass('disabled hidden'); + exportBtn.off('click'); } else { // > 1 ROWS SELECTED - var rows = []; _.each(rowsSelected, function(rowId) { rows.push($("tr[data-row-id='" + rowId + "']")[0]); }); @@ -519,44 +518,44 @@ function updateButtons() { // Only enable button if all selected rows can // be published/archived/restored/exported - editBtn.attr("disabled", "disabled"); - editBtn.off("click"); - cloneBtn.attr("disabled", "disabled"); - cloneBtn.off("click"); - if (!rows.is(":not([data-can-make-private])")) { - makePrivateBtn.removeAttr("disabled"); - makePrivateBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + editBtn.removeClass('hidden').addClass('disabled'); + editBtn.off('click'); + cloneBtn.removeClass('hidden').addClass('disabled'); + cloneBtn.off('click'); + if (!rows.is(':not([data-can-make-private])')) { + makePrivateBtn.removeClass('disabled hidden'); + makePrivateBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - makePrivateBtn.attr("disabled", "disabled"); - makePrivateBtn.off("click"); + makePrivateBtn.removeClass('hidden').addClass('disabled'); + makePrivateBtn.off('click'); } - if (!rows.is(":not([data-can-publish])")) { - publishBtn.removeAttr("disabled"); - publishBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + if (!rows.is(':not([data-can-publish])')) { + publishBtn.removeClass('disabled hidden'); + publishBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - publishBtn.attr("disabled", "disabled"); - publishBtn.off("click"); + publishBtn.removeClass('hidden').addClass('disabled'); + publishBtn.off('click'); } - if (!rows.is(":not([data-can-archive])")) { - archiveBtn.removeAttr("disabled"); - archiveBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + if (!rows.is(':not([data-can-archive])')) { + archiveBtn.removeClass('disabled hidden'); + archiveBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - archiveBtn.attr("disabled", "disabled"); - archiveBtn.off("click"); + archiveBtn.removeClass('hidden').addClass('disabled'); + archiveBtn.off('click'); } - if (!rows.is(":not([data-can-restore])")) { - restoreBtn.removeAttr("disabled"); - restoreBtn.off("click").on("click", function() { processMoveButtonClick($(this)); }); + if (!rows.is(':not([data-can-restore])')) { + restoreBtn.removeClass('disabled hidden'); + restoreBtn.off('click').on('click', function() { processMoveButtonClick($(this)); }); } else { - restoreBtn.attr("disabled", "disabled"); - restoreBtn.off("click"); + restoreBtn.removeClass('hidden').addClass('disabled'); + restoreBtn.off('click'); } - if (!rows.is(":not([data-can-export])")) { - exportBtn.removeAttr("disabled"); - exportBtn.off("click").on("click", function() { exportProtocols(rowsSelected); }); + if (!rows.is(':not([data-can-export])')) { + exportBtn.removeClass('disabled hidden'); + exportBtn.off('click').on('click', function() { exportProtocols(rowsSelected); }); } else { - exportBtn.attr("disabled", "disabled"); - exportBtn.off("click"); + exportBtn.removeClass('hidden').addClass('disabled'); + exportBtn.off('click'); } } } diff --git a/app/assets/javascripts/protocols/steps.js.erb b/app/assets/javascripts/protocols/steps.js.erb index b6d58275c..c03bc43ed 100644 --- a/app/assets/javascripts/protocols/steps.js.erb +++ b/app/assets/javascripts/protocols/steps.js.erb @@ -24,38 +24,6 @@ }); } - // Complete mymodule - function complete_my_module_actions() { - var modal = $('#completed-task-modal'); - - modal.find('[data-action="complete"]') - .off().on().click(function(event) { - event.stopPropagation(); - event.preventDefault(); - event.stopImmediatePropagation(); - $.ajax({ - url: modal.data('url'), - type: 'GET', - success: function(data) { - var task_button = $("[data-action='complete-task']"); - task_button.attr('data-action', 'uncomplete-task'); - task_button.find('.btn') - .removeClass('btn-toggle').addClass('btn-default'); - $('.task-due-date').html(data.module_header_due_date); - $('.task-state-label').html(data.module_state_label); - task_button - .find('button') - .html(' ' + - data.task_button_title); - modal.modal('hide'); - }, - error: function() { - modal.modal('hide'); - } - }); - }); - } - // Sets callback for completing/uncompleting step function applyStepCompletedCallBack() { // First, remove old event handlers, as we use turbolinks @@ -77,25 +45,20 @@ button = step.find("[data-action='complete-step']"); button.attr("data-action", "uncomplete-step"); - button.find(".btn").removeClass("btn-toggle").addClass("btn-default"); - button.find("button").html(' ' + data.new_title); - - if (data.task_ready_to_complete) { - $('#completed-task-modal').modal('show'); - complete_my_module_actions(); - } + button.html(' ' + data.new_title); } else { step.addClass("not-completed").removeClass("completed"); button = step.find("[data-action='uncomplete-step']"); button.attr("data-action", "complete-step"); - button.find(".btn").removeClass("btn-default").addClass("btn-toggle"); - button.find("button").html(' ' + data.new_title); + button.html(' ' + data.new_title); } }, - error: function (data) { - console.log ("error"); + error: function(response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } } }); }); @@ -157,6 +120,11 @@ tinyMCE.editors.step_description_textarea.remove(); TinyMCE.init('#step_description_textarea'); }); + }) + .on("ajax:error", function(e, response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } }); } @@ -166,44 +134,24 @@ if ($.isEmptyObject(data)) return; let $step = $(this).closest('.step'); - let stepUpPosition = data.step_up_position; - let stepDownPosition = data.step_down_position; - let $stepDown, $stepUp; + let steps = $('#steps').find('.step'); + $('#steps').append($.map(data.steps_order, function(step_data) { + let step = $('#steps').find(`.step[data-id=${step_data.id}]`); + step.find('.step-number').html(`${step_data.position + 1}.`); + return step; + })); - switch ($(this).data('direction')) { - case 'up': - $stepDown = $step.prev('.step'); - $stepUp = $step; - break; - case 'down': - $stepDown = $step; - $stepUp = $step.next('.step'); - } - - // Switch position of top and bottom steps - if (!_.isUndefined($stepDown) && !_.isUndefined($stepUp)) { - $stepDown.insertAfter($stepUp); - $stepDown.find('.step-number').html(`${stepDownPosition + 1}.`); - $stepUp.find('.step-number').html(`${stepUpPosition + 1}.`); - $('html, body').animate({ scrollTop: $step.offset().top - window.innerHeight / 2 }); - } + $('html, body').animate({ scrollTop: $step.offset().top - window.innerHeight / 2 }); if (typeof refreshProtocolStatusBar === 'function') refreshProtocolStatusBar(); + }) + .on("ajax:error", function(e, xhr) { + if (xhr.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } }); } - function applyCollapseLinkCallBack() { - $(".step-panel-collapse-link") - .on("ajax:success", function() { - var collapseIcon = $(this).find(".collapse-step-icon"); - var collapsed = $(this).hasClass("collapsed"); - // Toggle collapse button - collapseIcon.toggleClass("fa-chevron-up", !collapsed); - collapseIcon.toggleClass("fa-chevron-down", collapsed); - - }); - } - function formCallback($form) { $form .on("fields_added.nested_form_fields", function(e, param) { @@ -387,7 +335,6 @@ applyStepCompletedCallBack(); applyEditCallBack(); applyMoveStepCallBack(); - applyCollapseLinkCallBack(); initDeleteStep(); TinyMCE.highlight(); } @@ -518,8 +465,11 @@ }); }, - error: function() { - newStepHandler(); + error: function(response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + animateSpinner(null, false); + } } }) }); @@ -648,17 +598,10 @@ $(document).find("[data-role='step-hot-table']").each(function() { renderTable($(this)); }); - $(document).find("span.collapse-step-icon").each(function() { - $(this).addClass("fa-chevron-up"); - $(this).removeClass("fa-chevron-down"); - }); } function expandStep(step) { $('.panel-collapse', step).collapse('show'); - $(step).find("span.collapse-step-icon") - .addClass("fa-chevron-up") - .removeClass("fa-chevron-down"); $(step).find("div.step-result-hot-table").each(function() { renderTable($(this)); }); @@ -706,10 +649,6 @@ $(function () { $("[data-action='collapse-steps']").click(function () { $('.step .panel-collapse').collapse('hide'); - $(document).find("span.collapse-step-icon").each(function() { - $(this).addClass("fa-chevron-down"); - $(this).removeClass("fa-chevron-up"); - }); }); $("[data-action='expand-steps']").click(expandAllSteps); }); diff --git a/app/assets/javascripts/reports/reports_datatable.js.erb b/app/assets/javascripts/reports/reports_datatable.js.erb index 92901cdb6..ec578f0bb 100644 --- a/app/assets/javascripts/reports/reports_datatable.js.erb +++ b/app/assets/javascripts/reports/reports_datatable.js.erb @@ -114,14 +114,14 @@ var editReportButton = $('#edit-report-btn'); var deleteReportsButton = $('#delete-reports-btn'); if (CHECKED_REPORTS.length === 0) { - editReportButton.addClass("disabled"); - deleteReportsButton.addClass("disabled"); + editReportButton.addClass('disabled hidden'); + deleteReportsButton.addClass('disabled hidden'); } else if (CHECKED_REPORTS.length === 1) { - editReportButton.removeClass("disabled"); - deleteReportsButton.removeClass("disabled"); + editReportButton.removeClass('disabled hidden'); + deleteReportsButton.removeClass('disabled hidden'); } else { - editReportButton.addClass("disabled"); - deleteReportsButton.removeClass("disabled"); + editReportButton.removeClass('hidden').addClass('disabled'); + deleteReportsButton.removeClass('disabled hidden'); } } diff --git a/app/assets/javascripts/results/result_assets.js b/app/assets/javascripts/results/result_assets.js index fdb266a35..d8b92a117 100644 --- a/app/assets/javascripts/results/result_assets.js +++ b/app/assets/javascripts/results/result_assets.js @@ -68,7 +68,6 @@ initFormSubmitLinks($newResult); $(this).remove(); applyEditResultAssetCallback(); - Results.applyCollapseLinkCallBack(); Results.toggleResultEditButtons(true); Results.expandResult($newResult); diff --git a/app/assets/javascripts/results/result_tables.js.erb b/app/assets/javascripts/results/result_tables.js.erb index 96f09a49f..45a97dd37 100644 --- a/app/assets/javascripts/results/result_tables.js.erb +++ b/app/assets/javascripts/results/result_tables.js.erb @@ -47,7 +47,6 @@ $(this).remove(); applyEditResultTableCallback(); - Results.applyCollapseLinkCallBack(); Results.initHandsOnTables($result); Results.toggleResultEditButtons(true); Results.expandResult($result); diff --git a/app/assets/javascripts/results/result_texts.js b/app/assets/javascripts/results/result_texts.js index 3df90f7a4..1645e9b9c 100644 --- a/app/assets/javascripts/results/result_texts.js +++ b/app/assets/javascripts/results/result_texts.js @@ -73,7 +73,6 @@ initFormSubmitLinks(newResult); $(this).remove(); applyEditResultTextCallback(); - Results.applyCollapseLinkCallBack(); Results.toggleResultEditButtons(true); Results.expandResult(newResult); TinyMCE.destroyAll(); diff --git a/app/assets/javascripts/shared/inline_editing.js b/app/assets/javascripts/shared/inline_editing.js index b35631245..31a451869 100644 --- a/app/assets/javascripts/shared/inline_editing.js +++ b/app/assets/javascripts/shared/inline_editing.js @@ -1,5 +1,5 @@ /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "initInlineEditing" }]*/ -/* global SmartAnnotation */ +/* global SmartAnnotation HelperModule I18n */ var inlineEditing = (function() { const SIDEBAR_ITEM_TYPES = ['project', 'experiment', 'my_module', 'repository']; @@ -103,6 +103,9 @@ var inlineEditing = (function() { }, error: function(response) { var error = response.responseJSON[fieldToUpdate]; + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } if (!error) error = response.responseJSON.errors[fieldToUpdate]; container.addClass('error'); container.find('.error-block').html(error.join(', ')); diff --git a/app/assets/javascripts/sitewide/atwho_res.js b/app/assets/javascripts/sitewide/atwho_res.js new file mode 100644 index 000000000..327720b26 --- /dev/null +++ b/app/assets/javascripts/sitewide/atwho_res.js @@ -0,0 +1,241 @@ +/* global _ */ + +var SmartAnnotation = (function() { + 'use strict'; + + // stop the user annotation popover on click propagation + function atwhoStopPropagation(element) { + $(element).on('click', function(e) { + e.stopPropagation(); + e.preventDefault(); + }); + } + + function SetAtWho(field) { + var FilterTypeEnum = Object.freeze({ + USER: { tag: 'users', dataUrl: $(document.body).attr('data-atwho-users-url') }, + TASK: { tag: 'sa-tasks', dataUrl: $(document.body).attr('data-atwho-task-url') }, + PROJECT: { tag: 'sa-projects', dataUrl: $(document.body).attr('data-atwho-project-url') }, + EXPERIMENT: { tag: 'sa-experiments', dataUrl: $(document.body).attr('data-atwho-experiment-url') }, + REPOSITORY: { tag: 'sa-repositories', dataUrl: $(document.body).attr('data-atwho-rep-items-url') }, + MENU: { tag: 'menu', dataUrl: $(document.body).attr('data-atwho-menu-items') } + }); + var DEFAULT_SEARCH_FILTER = FilterTypeEnum.REPOSITORY; + + function matchHighlighter(html, query) { + var $html = $(html); + var $liText = $html.find('.item-text'); + if ($liText.length === 0 || !query) return html; + + $.each($liText, function(i, item) { + $(item).html($(item).text().replace(new RegExp(query.split(' ').join('|'), 'gi'), + '$&')); + }); + + return $html; + } + + // Generates suggestion dropdown filter + function generateFilterMenu() { + var menu = ''; + $.ajax({ + async: false, + dataType: 'json', + url: $(document.body).attr('data-atwho-repositories-url'), + success: function(data) { + menu = data.html; + } + }); + return menu; + } + + function atWhoSettings(at) { + return { + at: at, + callbacks: { + remoteFilter: function(query, callback) { + var $currentAtWho = $(`.atwho-view[data-at-who-id=${$(field).attr('data-smart-annotation')}]`); + var filterType; + var params = { query: query }; + filterType = FilterTypeEnum[$currentAtWho.find('.tab-pane.active').data('object-type')]; + if (!filterType) { + callback([{ name: '' }]); + return false; + } + if (filterType.tag === 'sa-repositories') { + let repositoryTab = $currentAtWho.find('[data-object-type="REPOSITORY"]'); + let activeRepository = repositoryTab.find('.btn-primary'); + if (activeRepository.length) { + params.repository_id = activeRepository.data('object-id'); + } + } + $.getJSON(filterType.dataUrl, params, function(data) { + localStorage.setItem('smart_annotation_states/teams/' + data.team, JSON.stringify({ + tag: filterType.tag, + repository: data.repository + })); + + callback(data.res); + + if (data.repository) { + $currentAtWho.find(`.repository-object[data-object-id="${data.repository}"]`) + .addClass('btn-primary').removeClass('btn-light'); + } + }); + return true; + }, + tplEval: function(_tpl, items) { + var $items = $(items.name); + $items.find('li').data('item-data', { 'atwho-at': at }); // Emulate at.js insertContentFor method + return $items; + }, + highlighter: function(li, query) { + return matchHighlighter(li, query); + }, + beforeInsert: function(value, li) { + return `[#${li.attr('data-name')}~${li.attr('data-type')}~${li.attr('data-id')}]`; + }, + matcher: function(flag, subtext, shouldStartWithSpace) { + var a; + var y; + var match; + var regexp; + var cleanedFlag = flag.replace(/[-[]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); + if (shouldStartWithSpace) { + cleanedFlag = '(?:^|\\s)' + cleanedFlag; + } + a = decodeURI('%C3%80'); + y = decodeURI('%C3%BF'); + regexp = new RegExp(`${cleanedFlag}$|${cleanedFlag}(\\S[A-Za-z${a}-${y}0-9_/:\\s+-]*)$|${cleanedFlag}(\\S[^\\x00-\\xff]*)$`, 'gi'); + match = regexp.exec(subtext); + if (match) { + return match[1] || ''; + } + return null; + } + }, + headerTpl: generateFilterMenu(), + startWithSpace: true, + acceptSpaceBar: true, + displayTimeout: 120000 + }; + } + + function init() { + $(field) + .on('shown.atwho', function() { + var $currentAtWho = $('.atwho-view[style]:not(.old)'); + var atWhoId = $currentAtWho.find('.atwho-header-res').data('at-who-key'); + $currentAtWho.addClass('old').attr('data-at-who-id', atWhoId); + $(field).attr('data-smart-annotation', atWhoId); + + $currentAtWho.find('.tab-button').off().on('shown.bs.tab', function() { + $(field).click().focus(); + $(this).closest('.nav-tabs').find('.tab-button').removeClass('active'); + $(this).addClass('active'); + }); + $currentAtWho.find('.repository-object').off().on('click', function() { + $(this).parent().find('.repository-object').removeClass('btn-primary') + .addClass('btn-light'); + $(this).addClass('btn-primary').removeClass('btn-light'); + $(field).click().focus(); + }); + + if ($currentAtWho.find('.tab-pane.active').length === 0) { + let filterType = DEFAULT_SEARCH_FILTER.tag; + let teamId = $currentAtWho.find('.atwho-header-res').data('team-id'); + let remeberedState = localStorage.getItem('smart_annotation_states/teams/' + teamId); + if (remeberedState) { + try { + remeberedState = JSON.parse(remeberedState); + filterType = remeberedState.tag; + $currentAtWho.find(`.repository-object[data-object-id=${remeberedState.repository}]`) + .addClass('btn-primary'); + } catch (error) { + console.error(error); + } + } + $currentAtWho.find(`.${filterType}`).click(); + } + }) + .on('reposition.atwho', function(event, flag, query) { + let inputFieldLeft = query.$inputor.offset().left; + if (inputFieldLeft > $(window).width()) { + let leftPosition; + if (inputFieldLeft < flag.left + $(window).scrollLeft()) { + leftPosition = inputFieldLeft; + } else { + leftPosition = flag.left + $(window).scrollLeft(); + } + query.$el.find('.atwho-view').css('left', leftPosition + 'px'); + } + if ($('.repository-show').length) { + query.$el.find('.atwho-view').css('top', flag.top + 'px'); + } + }) + .atwho({ + at: '@', + callbacks: { + remoteFilter: function(query, callback) { + $.getJSON(FilterTypeEnum.USER.dataUrl, { query: query }, function(data) { + callback(data.users); + }); + }, + tplEval: function(_tpl, items) { + var $items = $(items.name); + $items.find('li').data('item-data', { 'atwho-at': '@' }); // Emulate at.js insertContentFor method + return $items; + }, + highlighter: function(li, query) { + return matchHighlighter(li, query); + }, + beforeInsert: function(value, li) { + return `[@${li.attr('data-full-name')}~${li.attr('data-id')}]`; + } + }, + startsWithSpace: true, + acceptSpaceBar: true, + displayTimeout: 120000 + }) + .atwho(atWhoSettings('#')); + // .atwho(atWhoSettings('task#', FilterTypeEnum.TASK)) Waiting for better times + // .atwho(atWhoSettings('project#', FilterTypeEnum.PROJECT)) + // .atwho(atWhoSettings('experiment#', FilterTypeEnum.EXPERIMENT)) + // .atwho(atWhoSettings('sample#', FilterTypeEnum.REPOSITORY)); + } + + return { + init: init + }; + } + // Closes the atwho popup * needed in repositories to close the popup + // if nothing is selected and the user leaves the form * + function closePopup() { + $('.atwho-header-res').find('.fa-times').click(); + } + + function initialize(field) { + var atWho = new SetAtWho(field); + atWho.init(); + } + + return Object.freeze({ + init: initialize, + preventPropagation: atwhoStopPropagation, + closePopup: closePopup + }); +}()); + + +// initialize the smart annotations +(function() { + $(document).on('focus', '[data-atwho-edit]', function() { + if (_.isUndefined($(this).data('atwho'))) { + SmartAnnotation.init(this); + } + }); + + $(document).on('click', '.atwho-view .dismiss', function() { + $(this).closest('.atwho-view').hide(); + }); +}()); diff --git a/app/assets/javascripts/sitewide/atwho_res.js.erb b/app/assets/javascripts/sitewide/atwho_res.js.erb deleted file mode 100644 index b4100d7df..000000000 --- a/app/assets/javascripts/sitewide/atwho_res.js.erb +++ /dev/null @@ -1,520 +0,0 @@ -var SmartAnnotation = (function() { - 'use strict'; - - // utilities - var Util = (function() { - // helper method that binds show/hidden action - function showHideBinding() { - $.each(['show', 'hide'], function (i, ev) { - var el = $.fn[ev]; - $.fn[ev] = function () { - this.trigger(ev); - return el.apply(this, arguments); - }; - }); - } - - var publicApi = { - showHideBinding: showHideBinding - }; - - return publicApi; - })(); - - // stop the user annotation popover on click propagation - function atwhoStopPropagation(element) { - $(element).on('click', function(e) { - e.stopPropagation(); - e.preventDefault(); - }); - } - - function setAtWho(field) { - var FilterTypeEnum = Object.freeze({ - USER: {tag: "users", - dataUrl: $(document.body).attr('data-atwho-users-url')}, - TASK: {tag: "tsk", - dataUrl: $(document.body).attr('data-atwho-task-url')}, - PROJECT: {tag: "prj", - dataUrl: $(document.body).attr('data-atwho-project-url')}, - EXPERIMENT: {tag: "exp", - dataUrl: $(document.body).attr('data-atwho-experiment-url')}, - REPOSITORY: {tag: "rep", - dataUrl: $(document.body).attr('data-atwho-rep-items-url')}, - MENU: {tag: "menu", - dataUrl: $(document.body).attr('data-atwho-menu-items')} - }); - var prevAt, - // Default selected filter when using '#' - DEFAULT_SEARCH_FILTER = FilterTypeEnum.REPOSITORY, - atWhoUpdating = false; - - var defaultRepId; - - // helper methods for AtWho callback - function _templateEval(_tpl, map) { - var res; - try { - if (map.no_results) { - res = noResultsTemplate(); - } else { - res = generateTemplate(map); - } - } catch (_error) { - res = ''; - } - return res; - } - - function _matchHighlighter(li, query, filterType) { - var $li, re; - - function highlight(el, sel, re) { - var prevVal, newVal; - prevVal = el.find(sel).html(); - newVal = prevVal.replace(re, '$&'); - el.find(sel).html(newVal); - } - - if (!query || $(li).data('no-results')) { - return li; - } - - $li = $(li); - re = new RegExp(query, 'gi'); - // search_filter is not passed for the user - if(filterType) { - highlight($li, '[data-val=name]', re); - } else { - highlight($li, '[data-val=full-name]', re); - highlight($li, '[data-val=email]', re); - } - - return $li[0].outerHTML - } - - function _generateInputTag(value, li) { - var res = ''; - res += '[#' + li.attr('data-name'); - res += '~' + li.attr('data-type'); - res += '~' + li.attr('data-id') + ']'; - return res; - } - - // initialise dropdown dismiss button - function initDismissButton($currentAtWho) { - $currentAtWho.find('.dismiss').off('click') - .on('click', function() { - $(field).atwho('destroy'); - init(); - }); - } - - // Initialize or update dropdown header buttons - function updateHeaderButtons(query, filterType) { - var $currentAtWho = $('.atwho-view[style]'); - initDismissButton($currentAtWho); - - // Update the selected filter button when changing smart annotation type - $currentAtWho.find('[data-filter]') - .removeClass('btn-primary') - .addClass('btn-default'); - if(filterType.tag === 'rep') { - $currentAtWho.find('[data-rep-id="' + filterType.repository_id + '"]') - .removeClass('btn-default') - .addClass('btn-primary'); - } else { - $currentAtWho.find('[data-filter="' + filterType.tag + '"]') - .removeClass('btn-default') - .addClass('btn-primary'); - } - - // Update the selected filter button when clicking on one of them - $currentAtWho.find('[data-filter]').off() - .on('click', function(e) { - if($(this).hasClass('btn-primary')) { - return; - } - var $selectedBtn = $(this); - var $prevBtn = $selectedBtn.closest('.atwho-header-res') - .children('.btn-primary'); - $selectedBtn.removeClass('btn-default').addClass('btn-primary'); - $prevBtn.removeClass('btn-primary').addClass('btn-default'); - - // Updates query and dropdown elements; focuses input - $(field).click().focus(); - }); - } - - // Generates suggestion dropdown filter - function generateFilterMenu(active, res_data) { - var rep_buttons = ''; - $.ajax({ - async: false, - dataType: 'json', - url: $(document.body).attr('data-atwho-repositories-url'), - success: function(data) { - $.each(data['repositories'], function(id, name) { - if(defaultRepId === undefined){ - defaultRepId = id; - } - rep_buttons += ''; - }); - } - }); - var header = '
    ' + - '' + - '' + - '' + - rep_buttons + - '
    ' + - '' + - '
    ' + - '
    ' + - '
    ' + - '<%= I18n.t("atwho.users.navigate_1") %> ' + - '<%= I18n.t("atwho.users.navigate_2") %>' + - '
    ' + - '
    <%= I18n.t("atwho.users.confirm_1") %> ' + - '<%= I18n.t("atwho.users.confirm_2") %>' + - '
    ' + - '
    ' + - '<%= I18n.t("atwho.users.dismiss_1") %> ' + - '<%= I18n.t("atwho.users.dismiss_2") %>' + - '
    ' + - '
    ' + - '
    '; - - return header; - } - - function noResultsTemplate() { - var res = '
    '; - res += '<%= I18n.t("atwho.no_results") %>'; - res += '
    '; - return res; - } - - // Generates resources list items - function generateTemplate(map) { - var res = ''; - res += '
  • '; - switch(map.type) { - case 'tsk': - res += '' + map.type + ''; - break; - case 'prj': - res += '' + map.type + ''; - break; - case 'exp': - res += '' + map.type + ''; - break; - case 'rep_item': - res += '' + - map.repository_tag + ''; - break; - } - - res += ' '; - res += ''; - res += truncateLongString(map.name, - <%= Constants::NAME_TRUNCATION_LENGTH %>); - res += ''; - if(map.archived) { - res += '<%= I18n.t("atwho.res.archived") %>'; - } else { - res += ''; - } - res += ' '; - - switch (map.type) { - case 'tsk': - - res += '< ' + map.experimentName + - ' < ' + map.projectName + ''; - break; - case 'exp': - res += '< ' + map.projectName + ''; - break; - } - - res += '
  • '; - return res; - } - - /** - * Hackish wrapper function to make AtWho work when switching between - * multiple AtWho instances (e.g. from # to task#). - * - * Prevents second execution of AtWho update callback, triggered when user - * switches to different AtWho instance (e.g. from # to task#), which causes - * both of them to be called. In such case, AtWhO modal needs to be - * rerendered. - */ - function atWhoSwitchHack(filterTypeTag, remoteFilterCb) { - if(atWhoUpdating || (!$(field).length && _.isUndefined(filterTypeTag))) { - setTimeout(function() { - $(field).click(); - }, 100); - return; - } - - atWhoUpdating = true; - setTimeout(function() { - remoteFilterCb(); - atWhoUpdating = false; - }, 100); - } - - function atWhoSettings(at, defaultFilterType) { - return { - at: at, - callbacks: { - remoteFilter: function(query, callback) { - var $currentAtWho = $('.atwho-view[style]'); - var filterTypeTag = $currentAtWho - .find('.btn-primary') - .data('filter'); - - atWhoSwitchHack(filterTypeTag, function() { - var filterType; - if (_.isUndefined(filterTypeTag)) { - // Switched smart annotation type (i.e. changed input) - filterType = defaultFilterType; - } else { - // Switched filtering type (i.e. different filter button - // pressed; works also for specific annotation types, e.g. - // task#, and coverts to the correct annotation type on confirm) - $.each(FilterTypeEnum, function(k, v) { - if (v.tag == filterTypeTag) { - filterType = FilterTypeEnum[k]; - return false; - } - }); - } - if (prevAt != at) { - // Switching smart annotation type (i.e. chaned input) - - prevAt = at; - filterType = defaultFilterType; - // Hide current AtWho - $currentAtWho.removeAttr("style"); - } - - var params = { query: query }; - - if(filterType.tag === 'rep') { - params.repository_id = $currentAtWho - .find('.btn-primary') - .data('rep-id'); - if(params.repository_id === undefined) { - params.repository_id = defaultRepId; - } - filterType.repository_id = params.repository_id; - } - - $.getJSON( - filterType.dataUrl, - params, - function(data) { - // Updates dropdown - if (data.res.length < 1) { - callback([{no_results: 1}]); - } else { - callback(data.res); - } - - updateHeaderButtons(query, filterType); - } - ); - }); - }, - sorter: function(query, items, _searchKey) { - // Sorting is already done on server-side - return items; - }, - tplEval: function(_tpl, map) { - return _templateEval(_tpl, map); - }, - highlighter: function(li, query) { - return _matchHighlighter(li, query, true); - }, - beforeInsert: function(value, li) { - return _generateInputTag(value, li); - }, - matcher:function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var _a, _y, match, regexp, space; - flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); - if (should_startWithSpace) { - flag = '(?:^|\\s)' + flag; - } - _a = decodeURI("%C3%80"); - _y = decodeURI("%C3%BF"); - regexp = new RegExp(flag + "([A-Za-z" + _a + "-" + _y + "0-9_/:\\s\+\-\]*)$|" + flag + "([^\\x00-\\xff]*)$", 'gi'); - match = regexp.exec(subtext); - if (match) { - return match[2] || match[1]; - } else { - return null; - } - }, - }, - headerTpl: generateFilterMenu(defaultFilterType), - limit: <%= Constants::ATWHO_SEARCH_LIMIT %>, - startWithSpace: true, - acceptSpaceBar: true, - displayTimeout: 120000 - } - } - - function init() { - $(field) - .on("reposition.atwho", function(event, flag, query) { - let inputFieldLeft = query.$inputor.offset().left; - if (inputFieldLeft > $(window).width()) { - let leftPosition; - if (inputFieldLeft < flag.left + $(window).scrollLeft()) { - leftPosition = inputFieldLeft; - } else { - leftPosition = flag.left + $(window).scrollLeft(); - } - query.$el.find('.atwho-view').css('left', leftPosition + 'px'); - } - if ($('.repository-show').length) { - query.$el.find('.atwho-view').css('top', flag.top + 'px'); - } - }) - .atwho({ - at: '@', - callbacks: { - remoteFilter: function(query, callback) { - $.getJSON( - FilterTypeEnum.USER.dataUrl, - {query: query}, - function(data) { - if (data.users.length < 1) { - callback([{no_results: 1}]); - } else { - callback(data.users); - } - initDismissButton($('.atwho-view[style]')); - } - ); - }, - sorter: function(query, items, _searchKey) { - // Sorting is already done on server-side - return items; - }, - tplEval: function(_tpl, map) { - var res; - try { - if (map.no_results) { - res = noResultsTemplate(); - } else { - res = ''; - res += '
  • '; - res += ''; - res += ''; - res += map.full_name; - res += ''; - res += ''; - res += ' '; - res += '·'; - res += ' '; - res += ''; - res += map.email; - res += ''; - res += ''; - res += '
  • '; - } - } catch (_error) { - res = ''; - } - return res; - }, - highlighter: function(li, query) { - return _matchHighlighter(li, query); - }, - beforeInsert: function(value, li) { - var res = ''; - res += '[@' + li.attr('data-full-name'); - res += '~' + li.attr('data-id') + ']'; - return res; - } - }, - headerTpl: - '
    ' + - '
    <%= I18n.t("atwho.users.title") %>
    ' + - '
    ' + - '
    ' + - '<%= I18n.t("atwho.users.navigate_1") %> ' + - '<%= I18n.t("atwho.users.navigate_2") %>' + - '
    ' + - '
    ' + - '<%= I18n.t("atwho.users.confirm_1") %> ' + - '<%= I18n.t("atwho.users.confirm_2") %>' + - '
    ' + - '
    ' + - '<%= I18n.t("atwho.users.dismiss_1") %> ' + - '<%= I18n.t("atwho.users.dismiss_2") %>' + - '
    ' + - '
    ' + - '
    ' + - '' + - '
    ' + - '
    ', - limit: <%= Constants::ATWHO_SEARCH_LIMIT %>, - startsWithSpace: true, - acceptSpaceBar: true, - displayTimeout: 120000 - }) - .atwho(atWhoSettings('#', DEFAULT_SEARCH_FILTER)) - // .atwho(atWhoSettings('task#', FilterTypeEnum.TASK)) Waiting for better times - // .atwho(atWhoSettings('project#', FilterTypeEnum.PROJECT)) - // .atwho(atWhoSettings('experiment#', FilterTypeEnum.EXPERIMENT)); - } - - return { - init: init - }; - } - // Closes the atwho popup * needed in repositories to close the popup - // if nothing is selected and the user leaves the form * - function closePopup() { - $('.atwho-header-res').find('.fa-times').click(); - } - - function initialize(field) { - var atWho = new setAtWho(field); - atWho.init(); - } - - var publicApi = Object.freeze({ - init: initialize, - preventPropagation: atwhoStopPropagation, - closePopup: closePopup - }); - - return publicApi; - -})(); - - -// initialize the smart annotations -(function initSmartAnnotation() { - $(document).on('focus', '[data-atwho-edit]', function() { - if(_.isUndefined($(this).data('atwho'))) { - SmartAnnotation.init(this); - } - }); -})(); diff --git a/app/assets/javascripts/sitewide/comments.js b/app/assets/javascripts/sitewide/comments.js index f05db26c7..bf65005b1 100644 --- a/app/assets/javascripts/sitewide/comments.js +++ b/app/assets/javascripts/sitewide/comments.js @@ -1,4 +1,4 @@ -/* global inlineEditing PerfectScrollbar */ +/* global inlineEditing PerfectScrollbar HelperModule I18n */ /* eslint-disable no-restricted-globals, no-alert */ var Comments = (function() { function changeCounter(comment, value) { @@ -49,7 +49,11 @@ var Comments = (function() { $this.closest('.comment-container').remove(); }, error: (error) => { - alert(error.responseJSON.errors.message); + if (error.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } else { + alert(error.responseJSON.errors.message); + } } }); } @@ -85,6 +89,9 @@ var Comments = (function() { $el.find('textarea').focus().blur(); }) .error((error) => { + if (error.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } errorField.html(error.responseJSON.errors.message); newButton.disable = false; }); diff --git a/app/assets/javascripts/sitewide/drag_n_drop.js b/app/assets/javascripts/sitewide/drag_n_drop.js index 09fb99043..07a69637e 100644 --- a/app/assets/javascripts/sitewide/drag_n_drop.js +++ b/app/assets/javascripts/sitewide/drag_n_drop.js @@ -434,7 +434,6 @@ $.each($('[data-container="new-reports"]').find('.result'), function() { initFormSubmitLinks($(this)); ResultAssets.applyEditResultAssetCallback(); - Results.applyCollapseLinkCallBack(); Results.toggleResultEditButtons(true); FilePreviewModal.init(); Comments.init(); diff --git a/app/assets/javascripts/sitewide/dropdown_selector.js b/app/assets/javascripts/sitewide/dropdown_selector.js index da29489a9..2660b50c2 100644 --- a/app/assets/javascripts/sitewide/dropdown_selector.js +++ b/app/assets/javascripts/sitewide/dropdown_selector.js @@ -91,6 +91,9 @@ var dropdownSelector = (function() { // Get data in JSON from field function getCurrentData(container) { + if (!container.find('.data-field').val()) { + return ''; + } return JSON.parse(container.find('.data-field').val()); } @@ -179,8 +182,19 @@ var dropdownSelector = (function() { } // Add selected option to value - function addSelectedOption(selector, container) { - setData(selector, [convertOptionToJson($(selector).find('option:selected')[0])], true); + function addSelectedOptions(selector, container) { + var selectedOptions = []; + var optionSelector = selector.data('config').noEmptyOption ? 'option:selected' : 'option[data-selected=true]'; + $.each($(selector).find(optionSelector), function(i, option) { + selectedOptions.push(convertOptionToJson(option)); + if (selector.data('config').singleSelect) return false; + return true; + }); + + if (!selectedOptions.length) return false; + + setData(selector, selectedOptions, true); + return true; } // Prepare custom dropdown icon @@ -422,8 +436,8 @@ var dropdownSelector = (function() { } // Select default value - if (config.noEmptyOption && config.singleSelect) { - addSelectedOption(selectElement, dropdownContainer); + if (!selectElement.data('ajax-url')) { + addSelectedOptions(selectElement, dropdownContainer); } // Enable simple mode for dropdown selector @@ -849,17 +863,20 @@ var dropdownSelector = (function() { return this; }, - // Select value - selectValue: function(selector, value) { - var $selector; + // Select values + selectValues: function(selector, values) { + var $selector = $(selector); var option; + var valuesArray = [].concat(values); + var options = []; - if ($(selector).length === 0) return false; - - $selector = $(selector); - option = $selector.find(`option[value="${value}"]`)[0]; - setData($selector, [convertOptionToJson(option)]); + if ($selector.length === 0) return false; + valuesArray.forEach(function(value) { + option = $selector.find(`option[value="${value}"]`)[0]; + options.push(convertOptionToJson(option)); + }); + setData($selector, options); return this; }, diff --git a/app/assets/javascripts/sitewide/marvinjs_editor.js b/app/assets/javascripts/sitewide/marvinjs_editor.js index 5b3dc3aa5..5467d72d6 100644 --- a/app/assets/javascripts/sitewide/marvinjs_editor.js +++ b/app/assets/javascripts/sitewide/marvinjs_editor.js @@ -168,6 +168,10 @@ var MarvinJsEditorApi = (function() { $(marvinJsModal).modal('hide'); FilePreviewModal.init(); config.button.dataset.inProgress = false; + }).error((response) => { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } }); } @@ -196,6 +200,11 @@ var MarvinJsEditorApi = (function() { } $(marvinJsModal).modal('hide'); config.button.dataset.inProgress = false; + }, + error: function(response) { + if (response.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } } }); } diff --git a/app/assets/javascripts/sitewide/tiny_mce.js b/app/assets/javascripts/sitewide/tiny_mce.js index 042c1c6ef..20adaf09b 100644 --- a/app/assets/javascripts/sitewide/tiny_mce.js +++ b/app/assets/javascripts/sitewide/tiny_mce.js @@ -1,4 +1,4 @@ -/* global _ hljs tinyMCE SmartAnnotation I18n GLOBAL_CONSTANTS */ +/* global _ hljs tinyMCE SmartAnnotation I18n GLOBAL_CONSTANTS HelperModule */ /* eslint-disable no-unused-vars */ var TinyMCE = (function() { @@ -278,6 +278,9 @@ var TinyMCE = (function() { var model = editor.getElement().dataset.objectType; $(this).renderFormErrors(model, data.responseJSON); editor.setProgressState(0); + if (data.status === 403) { + HelperModule.flashAlertMsg(I18n.t('general.no_permissions'), 'danger'); + } }); // Init Cancel button diff --git a/app/assets/stylesheets/constants.scss b/app/assets/stylesheets/constants.scss index 00661294f..e22b9cccd 100644 --- a/app/assets/stylesheets/constants.scss +++ b/app/assets/stylesheets/constants.scss @@ -1,5 +1,3 @@ -@import url(https://fonts.googleapis.com/css?family=Lato:400,400i,700&subset=latin-ext); - //============================================================================== // Colors //============================================================================== diff --git a/app/assets/stylesheets/dashboard/current_tasks.scss b/app/assets/stylesheets/dashboard/current_tasks.scss index ae83941f0..3efb8afe3 100644 --- a/app/assets/stylesheets/dashboard/current_tasks.scss +++ b/app/assets/stylesheets/dashboard/current_tasks.scss @@ -66,7 +66,7 @@ margin-right: 4px; width: 36px; - .curent-tasks-filters { + .current-tasks-filters { padding: 0; width: 230px; @@ -133,28 +133,33 @@ } } - .current-tasks-list { - display: flex; - flex-direction: column; + .current-tasks-list-wrapper { height: 100%; overflow-y: auto; - padding: 0 10px; position: relative; + } + + .current-tasks-list { + align-items: center; + display: grid; + grid-template-columns: 1fr max-content max-content; + padding: 0 1em; &.disabled { pointer-events: none; } .current-task-item { - border-bottom: $border-tertiary; color: $color-volcano; - padding: 6px; + display: contents; text-decoration: none; .current-task-breadcrumbs { @include font-small; color: $color-silver-chalice; - line-height: 14px; + grid-column: span 3; + line-height: 1em; + padding: .5em .5em .25em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -166,134 +171,61 @@ } } - .item-row { - display: flex; + .row-border { + border-bottom: $border-tertiary; + height: 32px; + line-height: 24px; + padding-bottom: 8px; + } - .task-name { - flex-grow: 1; - font-size: $font-size-base; + .task-name { + font-size: $font-size-base; + font-weight: bold; + overflow: hidden; + padding: 0 .5em; + text-overflow: ellipsis; + white-space: nowrap; + } + + .task-due-date { + padding: 0 2em 0 1em; + + .fas { + padding: .25em; + } + + &.overdue { + color: $brand-danger; + } + + &.day-prior { + color: $brand-warning; + } + + &.completed { + color: $brand-success; + } + } + + .task-status-container { + grid-column: 3; + padding: 0 .5em; + text-align: right; + + .task-status { + @include font-small; + border-radius: $border-radius-tag; + color: $color-white; font-weight: bold; - overflow: hidden; - padding-right: 10px; - text-overflow: ellipsis; - white-space: nowrap; - } - - .task-due-date { - flex-basis: 280px; - flex-shrink: 0; - font-size: 14px; - - .fas { - padding: 4px; - } - - &.overdue { - color: $brand-danger; - } - - &.day-prior { - color: $brand-warning; - } - - &.completed { - color: $brand-success; - } + padding: .25em .5em; } } - &:hover { + &:hover > * { background: $color-concrete; } } } - - .task-progress-container { - height: 20px; - max-width: 250px; - min-width: 150px; - position: relative; - width: 100%; - - &::after { - @include font-small; - @include font-awesome; - content: ""; - line-height: 18px; - position: absolute; - right: 8px; - top: 1px; - } - - .task-progress { - background: $brand-focus-light; - border: $border-tertiary; - border-radius: $border-radius-tag; - display: flex; - height: 20px; - position: relative; - - &::after { - background: $color-white; - content: ""; - height: 18px; - width: 100%; - } - } - - .task-progress-label { - @include font-small; - font-weight: bold; - height: 20px; - left: 0; - line-height: 20px; - padding-left: 8px; - position: absolute; - top: 0; - width: calc(100% - 30px); - } - - &.overdue { - .task-progress { - background: $brand-danger-light; - } - - .task-progress-label { - color: $brand-danger; - } - - &::after { - color: $brand-danger; - content: $font-fas-exclamation-triangle; - } - } - - &.day-prior { - .task-progress-label { - color: $brand-warning; - } - } - - &.completed { - .task-progress { - outline: $border-success; - } - - .task-progress, - .task-progress::after { - background: $brand-success-light; - } - - .task-progress-label { - color: $brand-success; - } - - &::after { - color: $brand-success; - content: $font-fas-check; - } - } - } } @media (max-width: 1500px) { @@ -370,22 +302,37 @@ } .current-tasks-list { + grid-template-columns: auto; + .current-task-item { - .item-row { - flex-wrap: wrap; - .task-due-date { - @include font-small; + .current-task-breadcrumbs { + grid-column: 1; + padding-left: 0; + } - .fas { - display: none; - } + .task-name { + border: 0; + padding: 0; + height: 1.5em; + } + + .task-due-date { + @include font-small; + border: 0; + height: 24px; + padding-left: 0; + + + .fas { + display: none; } + } - .task-progress-container { - flex-basis: 100%; - max-width: none; - } + .task-status-container { + grid-column: 1; + text-align: left; + padding-left: 0; } } } diff --git a/app/assets/stylesheets/extend/datatable.scss b/app/assets/stylesheets/extend/datatable.scss index 1da42d628..ef49f5e1d 100644 --- a/app/assets/stylesheets/extend/datatable.scss +++ b/app/assets/stylesheets/extend/datatable.scss @@ -8,10 +8,9 @@ word-break: initial; thead { - background-color: $color-concrete; - tr { th { + background-color: $color-concrete; border-bottom: 2px solid $color-white; border-left: 2px solid $color-white; font-weight: bold; @@ -31,6 +30,11 @@ &:first-child { border-left: 0; + border-top-left-radius: $border-radius-default; + } + + &:last-child { + border-top-right-radius: $border-radius-default; } } diff --git a/app/assets/stylesheets/global_activities.scss b/app/assets/stylesheets/global_activities.scss index 1846f77b3..fc99ed398 100644 --- a/app/assets/stylesheets/global_activities.scss +++ b/app/assets/stylesheets/global_activities.scss @@ -169,20 +169,6 @@ .date-container { flex-shrink: 0; padding-right: 20px; - - .activities-group-expand-button { - user-select: none; - - &:hover, - &:visited, - &:focus { - text-decoration: none; - } - - .fas { - margin-right: 3px; - } - } } .date-activities { @@ -197,11 +183,24 @@ .activities-group-expand-button { color: $color-emperor; + user-select: none; + + &:hover, + &:visited, + &:focus { + text-decoration: none; + } .fas { display: inline-block; + margin-right: 3px; + text-align: center; width: 10px; } + + &:not(.collapsed) .fas { + @include rotate(90deg); + } } .activity-card { diff --git a/app/assets/stylesheets/my_modules.scss b/app/assets/stylesheets/my_modules.scss index e7d0f1637..b82e1fc79 100644 --- a/app/assets/stylesheets/my_modules.scss +++ b/app/assets/stylesheets/my_modules.scss @@ -166,10 +166,6 @@ display: flex; flex-wrap: wrap; max-width: 100%; - - .complete-step-btn { - width: 100%; - } } } diff --git a/app/assets/stylesheets/my_modules/protocols/index.scss b/app/assets/stylesheets/my_modules/protocols/index.scss index 8a0782a75..03b8f1520 100644 --- a/app/assets/stylesheets/my_modules/protocols/index.scss +++ b/app/assets/stylesheets/my_modules/protocols/index.scss @@ -503,6 +503,26 @@ } } +.task-information { + column-gap: 1em; + display: grid; + grid-template-columns: auto minmax(max-content, 18em); + + .task-section-header { + grid-column: 1 / span 1; + } + + .task-details { + grid-column: 1 / span 1; + grid-row: 2 / span 1; + } + + .task-flows { + grid-column: 2 / span 1; + grid-row: 1 / span 2; + } +} + @media (max-width: 700px) { .my-module-protocol-status { .status-info-dropdown { @@ -518,4 +538,18 @@ } } } + + .task-information { + grid-template-columns: auto; + row-gap: .5em; + + .task-details { + grid-row: 3 / span 1; + } + + .task-flows { + grid-column: unset; + grid-row: 2 / span 1; + } + } } diff --git a/app/assets/stylesheets/my_modules/repositories.scss b/app/assets/stylesheets/my_modules/repositories.scss index 5e218f422..93d6d0dc5 100644 --- a/app/assets/stylesheets/my_modules/repositories.scss +++ b/app/assets/stylesheets/my_modules/repositories.scss @@ -5,21 +5,9 @@ @include font-h3; line-height: 22px; overflow: hidden; - padding-right: 55px; position: relative; text-overflow: ellipsis; white-space: nowrap; - - &::after { - color: $color-alto; - content: '[' attr(data-rows-count) ']'; - display: inline-block; - line-height: 22px; - padding-left: 5px; - position: absolute; - right: 0; - width: 55px; - } } .my-module-inventories { @@ -131,6 +119,16 @@ .assigned-repository-title { @include my-module-repository-title; + padding-right: 2.2em; + + &::after { + color: $color-alto; + content: '[' attr(data-rows-count) ']'; + display: inline-block; + padding-right: .7em; + position: absolute; + right: 0; + } } .action-buttons { @@ -218,11 +216,26 @@ flex-grow: 1; max-width: calc(100% - 20px); - .repository-name { + .repository-name-container { + display: flex; + } + + .repository-title { @include my-module-repository-title; @include font-h2; - display: inline-block; - width: 100%; + } + + .repository-version { + @include font-h2; + flex-shrink: 0; + padding-right: .7em; + + &::after { + color: $color-alto; + content: '[' attr(data-rows-count) ']'; + display: inline-block; + padding-left: .3em; + } } .breadcrumbs { diff --git a/app/assets/stylesheets/my_modules/status_flow.scss b/app/assets/stylesheets/my_modules/status_flow.scss new file mode 100644 index 000000000..c82bfd9bb --- /dev/null +++ b/app/assets/stylesheets/my_modules/status_flow.scss @@ -0,0 +1,167 @@ +// scss-lint:disable SelectorDepth +// scss-lint:disable NestingDepth +// scss-lint:disable SelectorFormat +// scss-lint:disable ImportantRule + +@import "constants"; +@import "mixins"; + +.content-pane.my-modules-protocols-index { + .status-flow-dropdown { + .dropdown-toggle { + color: $color-white; + text-align: left; + width: 100%; + + .caret { + margin: 8px 0; + } + } + + &.open .dropdown-menu{ + align-items: center; + display: grid; + grid-template-columns: minmax(0, auto) 12px minmax(0, auto); + padding: .5em 0 0; + + li { + display: contents; + + > * { + cursor: pointer; + line-height: 2em; + padding: .5em 1em; + } + + &:hover > *{ + background: $color-concrete; + } + + &.disabled { + pointer-events: none; + + .status-name { + background: $color-alto !important; + } + } + } + + .fa-long-arrow-alt-right { + color: $color-silver-chalice; + padding: .5em 0; + } + + .status-container { + display: flex; + } + + .status-name { + border-radius: $border-radius-tag; + color: $color-white; + display: inline-block; + font-weight: bold; + line-height: 1em; + max-width: 100%; + overflow: hidden; + padding: .5em; + text-overflow: ellipsis; + white-space: nowrap; + } + + .error-message { + @include font-small; + color: $color-silver-chalice; + grid-column: span 3; + line-height: 1em; + padding: 0em 1em .5em; + + &:empty { + display: none; + } + + &.permission-error { + padding: .5em 1em; + } + } + + #viewTaskFlow { + border-top: $border-default; + cursor: pointer; + display: list-item; + grid-column: span 3; + line-height: 2em; + margin-top: .5em; + padding: .5em 1em; + } + } + } +} + + +#statusFlowModal { + .status-flow { + padding: 2em; + + .status-container { + align-items: center; + display: grid; + grid-template-columns: 1fr min-content 1fr; + grid-template-rows: 28px; + justify-content: space-around; + position: relative; + + .current-status { + @include font-small; + justify-self: end; + + .fas { + margin: 0 .5em; + } + } + + .status-block { + @include font-button; + border-radius: $border-radius-tag; + color: $color-white; + font-weight: bold; + line-height: 1em; + padding: .5em; + white-space: nowrap; + } + + .status-comment { + @include font-small; + color: $color-silver-chalice; + padding-left: .5em; + } + } + + .connector { + background: $color-black; + height: 2em; + margin: 0 auto; + position: relative; + width: 2px; + + &:before, + &:after { + border-left: .2em solid transparent; + border-right: .2em solid transparent; + content: ''; + display: block; + margin-left: -.1em; + position: absolute; + } + + &:before { + border-top: .2em solid $color-black; + top: 0; + } + + &:after { + border-bottom: .2em solid $color-black; + bottom: 0; + } + } + } +} diff --git a/app/assets/stylesheets/projects.scss b/app/assets/stylesheets/projects.scss index 19e9fa154..99324fa85 100644 --- a/app/assets/stylesheets/projects.scss +++ b/app/assets/stylesheets/projects.scss @@ -237,6 +237,7 @@ path, ._jsPlumb_endpoint { .panel-body .due-date-link { color: $color-emperor; + display: block; } .panel-body .due-date-label { @@ -316,10 +317,10 @@ path, ._jsPlumb_endpoint { .module-large .tags-container, .module-medium .tags-container { - padding-top: 2px; + padding-top: 4px; div { - font-size: 22pt; + font-size: 20px; width: 4px; height: 0px; display: inline-block; @@ -335,9 +336,9 @@ path, ._jsPlumb_endpoint { } & span.badge { - margin-left: -8px; - margin-top: -10px; + margin-left: -12px; margin-right: 4px; + margin-top: -7px; } } diff --git a/app/assets/stylesheets/reports.scss b/app/assets/stylesheets/reports.scss index 813af9f13..7a260209c 100644 --- a/app/assets/stylesheets/reports.scss +++ b/app/assets/stylesheets/reports.scss @@ -280,10 +280,17 @@ label { .module-start-date, .module-due-date { - margin-left: 5px; white-space: nowrap; } + .module-status { + .status-block { + border-radius: $border-radius-tag; + color: $color-white; + padding: 2px 4px; + } + } + .module-tags { margin-left: 0; margin-top: 10px; @@ -389,6 +396,16 @@ label { &:hover > .report-element-body .step-name { color: $brand-primary; } + + .step-label-default { + @include font-h3; + color: $color-alto; + } + + .step-label-success { + @include font-h3; + color: $brand-success; + } } /* Step attachment style (table, asset or checklist) */ diff --git a/app/assets/stylesheets/shared/datatable.scss b/app/assets/stylesheets/shared/datatable.scss index 514492f86..1d2089e38 100644 --- a/app/assets/stylesheets/shared/datatable.scss +++ b/app/assets/stylesheets/shared/datatable.scss @@ -87,4 +87,8 @@ margin-right: .5em; } } + + table > tbody > tr:first-child > td { + border-top: 0; + } } diff --git a/app/assets/stylesheets/shared/my_modules_list_partial.scss b/app/assets/stylesheets/shared/my_modules_list_partial.scss index 7a6858940..1370e154c 100644 --- a/app/assets/stylesheets/shared/my_modules_list_partial.scss +++ b/app/assets/stylesheets/shared/my_modules_list_partial.scss @@ -49,6 +49,8 @@ } .task-link { + color: $brand-primary; + cursor: pointer; line-height: 24px; overflow: hidden; text-overflow: ellipsis; diff --git a/app/assets/stylesheets/shared/smart_annotation.scss b/app/assets/stylesheets/shared/smart_annotation.scss new file mode 100644 index 000000000..fa449fa5d --- /dev/null +++ b/app/assets/stylesheets/shared/smart_annotation.scss @@ -0,0 +1,180 @@ +.atwho-view { + background: $color_white; + border-radius: $border-radius-default; + box-shadow: $modal-shadow; + display: none; + left: 0; + margin-top: 18px; + max-width: 700px; + min-width: 600px; + overflow: auto; + position: absolute; + top: 0; + z-index: 11110 !important; + + .atwho-header-res { + .nav-tabs { + align-items: center; + margin-bottom: 0; + } + + .rep-tab.active:not(:empty) { + border-bottom: $border-default; + display: flex; + padding: .25em; + } + + .dismiss { + @include font-button; + color: $color-silver-chalice; + cursor: pointer; + margin-left: auto; + padding: .5em .75em; + } + + .repository-object { + max-width: 250px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .atwho-view-ul { + margin: 0; + padding: 0; + } + + .atwho-no-results { + color: $color-silver-chalice; + padding: 1.5em 4em; + text-align: center; + + .description { + @include font-main; + padding: 0 4em 2.5em; + } + } + + .atwho-header { + @include font-small; + border-bottom: $border-default; + color: $color-silver-chalice; + padding: .5em; + + .dismiss { + @include font-button; + cursor: pointer; + float: right; + padding: 0 .25em; + position: relative; + } + } + + .atwho-footer { + @include font-small; + border-top: $border-default; + color: $color-silver-chalice; + padding: .5em; + white-space: pre; + } + + .atwho-scroll-container { + max-height: 200px; + overflow-y: auto; + padding: .5em; + position: relative; + + .atwho-breadcrumbs { + @include font-small; + color: $color-silver-chalice; + display: flex; + + .atwho-breadcrumb { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .slash { + margin: 0 .5em; + } + } + + .sa-type { + font-size: 8px; + } + + .item { + cursor: pointer; + margin-left: -.5em; + overflow: hidden; + padding: .25em .5em; + text-overflow: ellipsis; + width: calc(100% + 1em); + white-space: nowrap; + + &.cur { + background: $color-concrete; + } + + .atwho-highlight { + background: $brand-warning-light; + } + } + } + + .atwho-user { + align-items: center; + cursor: pointer; + display: flex; + padding: .5em 0; + + &.cur { + background: $color-concrete; + } + + .atwho-highlight { + background: $brand-warning-light; + } + + &:not:first-child { + border-top: $border-default; + } + + .avatar { + display: inline-block; + height: 30px; + width: 30px; + } + + .user-info { + display: inline-block; + margin-left: .5em; + } + + .user-email { + @include font-small; + color: $color-silver-chalice; + line-height: 1em; + } + } + + .more-results { + color: $color-silver-chalice; + padding: .5em 0; + } +} + +.sa-type { + font-size: 10px; + font-weight: 600; + padding-left: 2px; + text-decoration: none; + text-transform: uppercase; + vertical-align: super; + + &:hover { + text-decoration: none; + } +} diff --git a/app/assets/stylesheets/shared_styles/elements/bootstrap_tabs.scss b/app/assets/stylesheets/shared_styles/elements/bootstrap_tabs.scss new file mode 100644 index 000000000..e4d2cb402 --- /dev/null +++ b/app/assets/stylesheets/shared_styles/elements/bootstrap_tabs.scss @@ -0,0 +1,28 @@ +.sci-nav-tabs { + border-bottom: $border-default; + display: flex; + + a { + color: $color-volcano; + padding: .5em; + position: relative; + + &:hover { + text-decoration: none; + } + + &.active { + color: initial; + + &::after { + content: ''; + background: $brand-primary; + bottom: 0; + height: .25em; + left: 0; + position:absolute; + width: 100%; + } + } + } +} diff --git a/app/assets/stylesheets/shared_styles/elements/dropdown.scss b/app/assets/stylesheets/shared_styles/elements/dropdown.scss index fbe970ac0..fc248e3da 100644 --- a/app/assets/stylesheets/shared_styles/elements/dropdown.scss +++ b/app/assets/stylesheets/shared_styles/elements/dropdown.scss @@ -16,7 +16,7 @@ border-color: $brand-focus; .caret { - transform: rotateX(180deg) + transform: rotateX(180deg); } } diff --git a/app/assets/stylesheets/steps.scss b/app/assets/stylesheets/steps.scss index df9d1872f..47140a260 100644 --- a/app/assets/stylesheets/steps.scss +++ b/app/assets/stylesheets/steps.scss @@ -20,6 +20,16 @@ } } } + + .checklist-name-container, + .table-name-container { + align-items: center; + display: flex; + + .remove-container { + padding-top: 10px; + } + } } #steps { @@ -40,8 +50,12 @@ } } - .complete-step-btn { - display: inline-block; + .step-panel-collapse-link { + padding-left: 5px; + + &:not(.collapsed) .fas { + @include rotate(90deg); + } } .step-heading { diff --git a/app/assets/stylesheets/themes/scinote.scss b/app/assets/stylesheets/themes/scinote.scss index c7677fa3d..9b23d2c13 100644 --- a/app/assets/stylesheets/themes/scinote.scss +++ b/app/assets/stylesheets/themes/scinote.scss @@ -133,6 +133,14 @@ body { font-size: $font-size-small; } +.modal-body { + font-size: $font-size-base; + + label { + @include font-small; + } +} + .jumbotron { background-color: inherit; } @@ -742,6 +750,47 @@ ul.double-line > li { } } +#canvas-container { + .panel-heading { + padding: 10px 15px 4px; + } + + .panel-body { + padding: 6px 15px; + + .status-label { + background-color: var(--state-color); + color: $color-white; + display: inline-block; + margin: 3px 0; + max-width: 100%; + overflow: hidden; + padding: 2px 8px; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .panel-footer { + .nav > li > a { + padding: 6px 15px; + } + + .btn { + height: 30px; + } + + .badge-indicator { + background: transparent; + color: $color-silver-chalice; + font-size: 12px; + margin-left: 0; + padding: 0; + top: 0; + } + } +} + .panel-options { position: relative; bottom: 8px; @@ -1112,6 +1161,15 @@ ul.content-activities { .result-panel-collapse-link { text-decoration: none; + + &:not(.collapsed) .fas { + @include rotate(90deg); + } + + .fas { + margin-right: 7px; + text-align: center; + } } .row { @@ -1647,199 +1705,6 @@ th.custom-field .modal-tooltiptext { pointer-events: none; } -// AtWho (smart annotations) - -// -.atwho-view { - position: absolute; - top: 0; - left: 0; - display: none; - margin-top: 18px; - background: $color-white; - color: $color-black; - border: 1px solid $color-emperor; - border-radius: 3px; - box-shadow: 0 0 5px $color-gainsboro; - max-width: 800px; - min-width: 700px; - overflow: auto; - z-index: 11110 !important; - - small { - font-size: smaller; - color: $color-emperor; - font-weight: normal; - } - - strong { - color: $brand-primary; - } - - .cur { - background: $brand-primary; - color: $color-white; - - small { - color: $color-white; - } - - strong { - color: $color-white; - font: bold; - } - - .res-description { - color: $color-white; - } - - .res-type { - border-color: $color-white; - } - } - - ul { - list-style: none; - padding: 0; - margin: auto; - - li { - align-items: center; - border-bottom: 1px solid $color-emperor; - cursor: pointer; - display: flex; - padding: 5px 10px; - - .global-avatar-container { - margin-right: 5px; - } - } - } -} -// - -.atwho-header-res { - background-color: $color-concrete; - border-bottom: 1px solid $color-emperor; - display: flex; - flex-wrap: wrap; - padding: 3px 5px; - - .btn { - border-radius: 4px; - margin: 5px; - padding: 3px; - } - - .btn-default { - background-color: transparent; - } - - .title-user { - padding-top: 4px; - } - - .help { - margin-left: auto; - margin-right: 15px; - order: 99; - padding: 4px; - white-space: nowrap; - - div { - display: inline; - font-size: smaller; - margin-left: 15px; - } - - strong { - color: $color-black; - } - } - - .dismiss { - color: $color-emperor; - position: absolute; - right: 5px; - top: 5px; - } - - .dismiss:hover { - color: $color-black; - cursor: pointer; - } -} - - -.atwho-li-res { - - .fa-tint { - margin-left: 5px; - margin-right: 5px; - } - - .res-type { - border: 1px solid $color-black; - border-radius: 4px; - font-weight: 600; - margin-left: 5px; - margin-right: 5px; - padding: 0 2px; - text-transform: capitalize; - } - - .res-name { - font-weight: 600; - margin-right: 5px; - } - - .res-description { - color: $color-emperor; - font-size: 10px; - } -} - -.sa-type { - border: 1px solid $color-emperor; - border-radius: 4px; - font-weight: 600; - padding: 0 2px; - text-decoration: none; - text-transform: capitalize; - - &:hover { - text-decoration: none; - } -} - -.atwho-user-container { - align-items: center; - display: inline-flex; - - .global-avatar-container { - line-height: 30px; - margin: 0 2px 0 4px; - - img { - position: relative; - top: -2px; - } - } -} - -.atwho-user-popover { - cursor: pointer; - display: inline-block; -} - -.atwho-user-img-popover { - cursor: default; -} - -.atwho-no-results { - padding: 5px 10px; -} - .popover { border-radius: 3px; min-width: 450px; diff --git a/app/controllers/active_storage/representations_controller.rb b/app/controllers/active_storage/representations_controller.rb index 928d292f1..68cca5585 100644 --- a/app/controllers/active_storage/representations_controller.rb +++ b/app/controllers/active_storage/representations_controller.rb @@ -31,7 +31,9 @@ module ActiveStorage unless processing ActiveStorage::PreviewJob.perform_later(@blob.id) - @blob.attachments.take.record.update(file_processing: true) + ActiveRecord::Base.no_touching do + @blob.attachments.take.record.update(file_processing: true) + end end false diff --git a/app/controllers/api/v1/assets_controller.rb b/app/controllers/api/v1/assets_controller.rb index 358e49708..140fe992a 100644 --- a/app/controllers/api/v1/assets_controller.rb +++ b/app/controllers/api/v1/assets_controller.rb @@ -23,14 +23,14 @@ module Api raise PermissionError.new(Asset, :create) unless can_manage_protocol_in_module?(@protocol) if @form_multipart_upload - asset = @step.assets.new(asset_params) + asset = @step.assets.new(asset_params.merge({ team_id: @team.id })) else blob = ActiveStorage::Blob.create_after_upload!( io: StringIO.new(Base64.decode64(asset_params[:file_data])), filename: asset_params[:file_name], content_type: asset_params[:file_type] ) - asset = @step.assets.new(file: blob) + asset = @step.assets.new(file: blob, team: @team) end asset.save!(context: :on_api_upload) diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index 79d5a0ea8..70acc12e4 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -201,6 +201,10 @@ module Api @checklist_item = @checklist.checklist_items.find(params.require(key)) raise PermissionError.new(Protocol, :read) unless can_read_protocol_in_module?(@step.protocol) end + + def load_workflow(key = :workflow_id) + @workflow = MyModuleStatusFlow.find(params.require(key)) + end end end end diff --git a/app/controllers/api/v1/experiments_controller.rb b/app/controllers/api/v1/experiments_controller.rb index f2b6aa824..761eff5e9 100644 --- a/app/controllers/api/v1/experiments_controller.rb +++ b/app/controllers/api/v1/experiments_controller.rb @@ -8,6 +8,7 @@ module Api before_action only: :show do load_experiment(:id) end + before_action :load_experiment_for_managing, only: %i(update) def index experiments = @project.experiments @@ -19,6 +20,47 @@ module Api def show render jsonapi: @experiment, serializer: ExperimentSerializer end + + def create + raise PermissionError.new(Experiment, :create) unless can_create_experiments?(@project) + + experiment = @project.experiments.create!(experiment_params.merge!(created_by: current_user, + last_modified_by: current_user)) + + render jsonapi: experiment, serializer: ExperimentSerializer, status: :created + end + + def update + @experiment.assign_attributes(experiment_params) + + return render body: nil, status: :no_content unless @experiment.changed? + + if @experiment.archived_changed? + if @experiment.archived? + @experiment.archived_by = current_user + @experiment.archived_on = DateTime.now + else + @experiment.restored_by = current_user + @experiment.restored_on = DateTime.now + end + end + @experiment.last_modified_by = current_user + @experiment.save! + render jsonapi: @experiment, serializer: ExperimentSerializer, status: :ok + end + + private + + def experiment_params + raise TypeError unless params.require(:data).require(:type) == 'experiments' + + params.require(:data).require(:attributes).permit(:name, :description, :archived) + end + + def load_experiment_for_managing + @experiment = @project.experiments.find(params.require(:id)) + raise PermissionError.new(Experiment, :manage) unless can_manage_experiment?(@experiment) + end end end end diff --git a/app/controllers/api/v1/projects_controller.rb b/app/controllers/api/v1/projects_controller.rb index c527e7761..8bc2ce54a 100644 --- a/app/controllers/api/v1/projects_controller.rb +++ b/app/controllers/api/v1/projects_controller.rb @@ -8,6 +8,7 @@ module Api load_project(:id) end before_action :load_project, only: :activities + before_action :load_project_for_managing, only: %i(update) def index projects = @team.projects @@ -22,6 +23,31 @@ module Api render jsonapi: @project, serializer: ProjectSerializer end + def create + raise PermissionError.new(Project, :create) unless can_create_projects?(@team) + + project = @team.projects.create!(project_params.merge!(created_by: current_user)) + + render jsonapi: project, serializer: ProjectSerializer, status: :created + end + + def update + @project.assign_attributes(project_params) + + return render body: nil, status: :no_content unless @project.changed? + + if @project.archived_changed? + if @project.archived? + @project.archived_by = current_user + else + @project.restored_by = current_user + end + end + @project.last_modified_by = current_user + @project.save! + render jsonapi: @project, serializer: ProjectSerializer, status: :ok + end + def activities activities = @project.activities .page(params.dig(:page, :number)) @@ -29,6 +55,19 @@ module Api render jsonapi: activities, each_serializer: ActivitySerializer end + + private + + def project_params + raise TypeError unless params.require(:data).require(:type) == 'projects' + + params.require(:data).require(:attributes).permit(:name, :visibility, :archived) + end + + def load_project_for_managing + @project = @team.projects.find(params.require(:id)) + raise PermissionError.new(Project, :manage) unless can_manage_project?(@project) + end end end end diff --git a/app/controllers/api/v1/results_controller.rb b/app/controllers/api/v1/results_controller.rb index 6d6bcde2d..0760ed813 100644 --- a/app/controllers/api/v1/results_controller.rb +++ b/app/controllers/api/v1/results_controller.rb @@ -104,10 +104,10 @@ module Api Result.transaction do @result = @task.results.create!(result_params.merge(user_id: current_user.id)) if @form_multipart_upload - asset = Asset.create!(result_file_params) + asset = Asset.create!(result_file_params.merge({ team_id: @team.id })) else blob = create_blob_from_params - asset = Asset.create!(file: blob) + asset = Asset.create!(file: blob, team: @team) end ResultAsset.create!(asset: asset, result: @result) end diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb index ac4696d82..635082f88 100644 --- a/app/controllers/api/v1/tasks_controller.rb +++ b/app/controllers/api/v1/tasks_controller.rb @@ -16,6 +16,7 @@ module Api def index tasks = @experiment.my_modules + .includes(:my_module_status, :my_modules, :my_module_antecessors) .page(params.dig(:page, :number)) .per(params.dig(:page, :size)) @@ -29,7 +30,7 @@ module Api def create raise PermissionError.new(MyModule, :create) unless can_manage_experiment?(@experiment) - my_module = @experiment.my_modules.create!(task_params) + my_module = @experiment.my_modules.create!(task_params_create) render jsonapi: my_module, serializer: TaskSerializer, rte_rendering: render_rte?, @@ -37,7 +38,7 @@ module Api end def update - @task.assign_attributes(task_params) + @task.assign_attributes(task_params_update) if @task.changed? && @task.save! render jsonapi: @task, serializer: TaskSerializer, status: :ok @@ -56,10 +57,16 @@ module Api private - def task_params + def task_params_create raise TypeError unless params.require(:data).require(:type) == 'tasks' - params.require(:data).require(:attributes).permit(%i(name x y description state)) + params.require(:data).require(:attributes).permit(%i(name x y description)) + end + + def task_params_update + raise TypeError unless params.require(:data).require(:type) == 'tasks' + + params.require(:data).require(:attributes).permit(%i(name x y description my_module_status_id)) end def load_task_for_managing diff --git a/app/controllers/api/v1/user_projects_controller.rb b/app/controllers/api/v1/user_projects_controller.rb index d583c77bd..1e10d47ca 100644 --- a/app/controllers/api/v1/user_projects_controller.rb +++ b/app/controllers/api/v1/user_projects_controller.rb @@ -6,6 +6,7 @@ module Api before_action :load_team before_action :load_project before_action :load_user_project, only: :show + before_action :load_user_project_for_managing, only: %i(update destroy) def index user_projects = @project.user_projects @@ -23,11 +24,44 @@ module Api include: :user end + def create + raise PermissionError.new(Project, :manage) unless can_manage_project?(@project) + + user_project = @project.user_projects.create!(user_project_params.merge!(assigned_by: current_user)) + + render jsonapi: user_project, serializer: UserProjectSerializer, status: :created + end + + def update + @user_project.role = user_project_params[:role] + return render body: nil, status: :no_content unless @user_project.changed? + + @user_project.assigned_by = current_user + @user_project.save! + render jsonapi: @user_project, serializer: UserProjectSerializer, status: :ok + end + + def destroy + @user_project.destroy! + render body: nil + end + private def load_user_project @user_project = @project.user_projects.find(params.require(:id)) end + + def load_user_project_for_managing + @user_project = @project.user_projects.find(params.require(:id)) + raise PermissionError.new(Project, :manage) unless can_manage_project?(@project) + end + + def user_project_params + raise TypeError unless params.require(:data).require(:type) == 'user_projects' + + params.require(:data).require(:attributes).permit(:user_id, :role) + end end end end diff --git a/app/controllers/api/v1/workflow_statuses_controller.rb b/app/controllers/api/v1/workflow_statuses_controller.rb new file mode 100644 index 000000000..6114d0c25 --- /dev/null +++ b/app/controllers/api/v1/workflow_statuses_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Api + module V1 + class WorkflowStatusesController < BaseController + before_action only: :index do + load_workflow(:workflow_id) + end + + def index + statuses = @workflow.my_module_statuses + render jsonapi: statuses, each_serializer: WorkflowStatusSerializer + end + end + end +end diff --git a/app/controllers/api/v1/workflows_controller.rb b/app/controllers/api/v1/workflows_controller.rb new file mode 100644 index 000000000..dfb5e7618 --- /dev/null +++ b/app/controllers/api/v1/workflows_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Api + module V1 + class WorkflowsController < BaseController + before_action only: :show do + load_workflow(:id) + end + + def index + workflows = MyModuleStatusFlow.all + render jsonapi: workflows, each_serializer: WorkflowSerializer + end + + def show + render jsonapi: @workflow, serializer: WorkflowSerializer + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2268c2e1c..db89d7e6f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -37,7 +37,7 @@ class ApplicationController < ActionController::Base # Sets current team for all controllers def current_team - Team.find_by_id(current_user.current_team_id) + @current_team ||= current_user.teams.find_by(id: current_user.current_team_id) end def to_user_date_format @@ -83,13 +83,12 @@ class ApplicationController < ActionController::Base private def update_current_team - current_team = Team.find_by_id(current_user.current_team_id) - if (current_team.nil? || !current_user.is_member_of_team?(current_team)) && - current_user.teams.count.positive? + return if current_team.present? && current_team.id == current_user.current_team_id - current_user.update( - current_team_id: current_user.teams.first.id - ) + if current_user.current_team_id + @current_team = current_user.teams.find_by(id: current_user.current_team_id) + elsif current_user.teams.any? + current_user.update(current_team_id: current_user.teams.first.id) end end diff --git a/app/controllers/at_who_controller.rb b/app/controllers/at_who_controller.rb index ace31495b..c46ab54ef 100644 --- a/app/controllers/at_who_controller.rb +++ b/app/controllers/at_who_controller.rb @@ -5,10 +5,11 @@ class AtWhoController < ApplicationController before_action :check_users_permissions def users + users = @team.search_users(@query).limit(Constants::ATWHO_SEARCH_LIMIT + 1) respond_to do |format| format.json do render json: { - users: generate_users_data, + users: [render_to_string(partial: 'shared/smart_annotation/users.html.erb', locals: {users: users})], status: :ok } end @@ -31,7 +32,7 @@ class AtWhoController < ApplicationController end def rep_items - repository = Repository.find_by_id(params[:repository_id]) + repository = Repository.find_by_id(params[:repository_id]) || Repository.active.accessible_by_teams(@team).first items = if repository && can_read_repository?(repository) SmartAnnotation.new(current_user, current_team, @query) @@ -42,25 +43,21 @@ class AtWhoController < ApplicationController respond_to do |format| format.json do render json: { - res: items, + res: [render_to_string(partial: 'shared/smart_annotation/repository_items.html.erb', locals: { + repository_rows: items + })], + repository: repository.id, + team: current_team.id, status: :ok } end end end - def repositories + def menu repositories = Repository.active.accessible_by_teams(@team) - respond_to do |format| - format.json do - render json: { - repositories: repositories.map do |r| - [r.id, escape_input(r.name.truncate(Constants::ATWHO_REP_NAME_LIMIT))] - end.to_h, - status: :ok - } - end - end + render json: { html: render_to_string({ partial: "shared/smart_annotation/menu.html.erb", + locals: { repositories: repositories } }) } end def projects @@ -68,7 +65,10 @@ class AtWhoController < ApplicationController respond_to do |format| format.json do render json: { - res: res.projects, + res: [render_to_string(partial: 'shared/smart_annotation/project_items.html.erb', locals: { + projects: res.projects + })], + team: current_team.id, status: :ok } end @@ -80,7 +80,10 @@ class AtWhoController < ApplicationController respond_to do |format| format.json do render json: { - res: res.experiments, + res: [render_to_string(partial: 'shared/smart_annotation/experiment_items.html.erb', locals: { + experiments: res.experiments + })], + team: current_team.id, status: :ok } end @@ -92,7 +95,10 @@ class AtWhoController < ApplicationController respond_to do |format| format.json do render json: { - res: res.my_modules, + res: [render_to_string(partial: 'shared/smart_annotation/my_module_items.html.erb', locals: { + my_modules: res.my_modules + })], + team: current_team.id, status: :ok } end @@ -110,23 +116,4 @@ class AtWhoController < ApplicationController def check_users_permissions render_403 unless can_read_team?(@team) end - - def generate_users_data - # Search users - res = @team.search_users(@query) - .limit(Constants::ATWHO_SEARCH_LIMIT) - .pluck(:id, :full_name, :email) - - # Add avatars, Base62, convert to JSON - data = [] - res.each do |obj| - tmp = {} - tmp['id'] = obj[0].base62_encode - tmp['full_name'] = escape_input(obj[1].truncate(Constants::NAME_TRUNCATION_LENGTH_DROPDOWN)) - tmp['email'] = escape_input(obj[2]) - tmp['img_url'] = avatar_path(obj[0], :icon_small) - data << tmp - end - data - end end diff --git a/app/controllers/dashboard/current_tasks_controller.rb b/app/controllers/dashboard/current_tasks_controller.rb index 30cd0b22a..808e07dea 100644 --- a/app/controllers/dashboard/current_tasks_controller.rb +++ b/app/controllers/dashboard/current_tasks_controller.rb @@ -25,7 +25,7 @@ module Dashboard tasks = tasks.left_outer_joins(:user_my_modules).where(user_my_modules: { user_id: current_user.id }) end - tasks = filter_by_state(tasks) + tasks = tasks.where(my_module_status_id: task_filters[:statuses]) case task_filters[:sort] when 'start_date' @@ -41,7 +41,9 @@ module Dashboard end page = (params[:page] || 1).to_i - tasks = tasks.with_step_statistics.search_by_name(current_user, current_team, task_filters[:query]) + tasks = tasks.search_by_name(current_user, current_team, task_filters[:query]) + .joins(:my_module_status) + .select('my_modules.*', 'my_module_statuses.name as status_name', 'my_module_statuses.color as status_color') .preload(experiment: :project).page(page).per(Constants::INFINITE_SCROLL_LIMIT) tasks_list = tasks.map do |task| @@ -50,9 +52,9 @@ module Dashboard experiment: escape_input(task.experiment.name), project: escape_input(task.experiment.project.name), name: escape_input(task.name), - due_date: task.due_date.present? ? I18n.l(task.due_date, format: :full_date) : nil, - state: task_state(task), - steps_precentage: task.steps_completed_percentage } + due_date: prepare_due_date(task), + status_color: task.status_color, + status_name: task.status_name } end render json: { data: tasks_list, next_page: tasks.next_page } @@ -90,31 +92,28 @@ module Dashboard private - def task_state(task) - if task.state == 'completed' - task_state_class = task.state - task_state_text = t('dashboard.current_tasks.progress_bar.completed') - else - task_state_text = t('dashboard.current_tasks.progress_bar.in_progress') - task_state_class = 'day-prior' if task.is_one_day_prior? - if task.is_overdue? - task_state_text = t('dashboard.current_tasks.progress_bar.overdue') - task_state_class = 'overdue' - end - if task.steps_total.positive? - task_state_text += t('dashboard.current_tasks.progress_bar.completed_steps', - steps: task.steps_completed, total_steps: task.steps_total) - end + def prepare_due_date(task) + if task.completed? + return { state: '', text: I18n.t('dashboard.current_tasks.completed_on_html', + date: I18n.l(task.completed_on, format: :full_date)) } end - { text: task_state_text, class: task_state_class } - end + if task.due_date.present? + due_date_formatted = I18n.l(task.due_date, format: :full_date) + if task.is_overdue? + return { state: 'overdue', text: I18n.t('dashboard.current_tasks.due_date_overdue_html', + date: due_date_formatted) } + elsif task.is_one_day_prior? + return { state: 'day-prior', text: I18n.t('dashboard.current_tasks.due_date_html', + date: due_date_formatted) } + end - def filter_by_state(tasks) - tasks.where(my_modules: { state: task_filters[:view] }) + return { state: '', text: I18n.t('dashboard.current_tasks.due_date_html', date: due_date_formatted) } + end + { state: nil, text: nil } end def task_filters - params.permit(:project_id, :experiment_id, :mode, :view, :sort, :query, :page) + params.permit(:project_id, :experiment_id, :mode, :sort, :query, :page, statuses: []) end def load_project diff --git a/app/controllers/dashboards_controller.rb b/app/controllers/dashboards_controller.rb index 2e4880fad..bbc91737d 100644 --- a/app/controllers/dashboards_controller.rb +++ b/app/controllers/dashboards_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true class DashboardsController < ApplicationController - def show; end + def show + @my_module_status_flows = MyModuleStatusFlow.all.preload(my_module_statuses: :my_module_status_consequences) + end end diff --git a/app/controllers/my_module_repositories_controller.rb b/app/controllers/my_module_repositories_controller.rb index 7ecc50060..eccb3d7a9 100644 --- a/app/controllers/my_module_repositories_controller.rb +++ b/app/controllers/my_module_repositories_controller.rb @@ -123,7 +123,7 @@ class MyModuleRepositoriesController < ApplicationController Activities::CreateActivityService.call( activity_type: :export_inventory_items_assigned_to_task, owner: current_user, - subject: @repository, + subject: @my_module, team: current_team, message_items: { my_module: @my_module.id, diff --git a/app/controllers/my_module_repository_snapshots_controller.rb b/app/controllers/my_module_repository_snapshots_controller.rb index 379ed0155..7fa02e11d 100644 --- a/app/controllers/my_module_repository_snapshots_controller.rb +++ b/app/controllers/my_module_repository_snapshots_controller.rb @@ -31,7 +31,8 @@ class MyModuleRepositorySnapshotsController < ApplicationController end def create - repository_snapshot = @repository.provision_snapshot(@my_module, current_user) + repository_snapshot = RepositorySnapshot.create_preliminary(@repository, @my_module, current_user) + RepositorySnapshotProvisioningJob.perform_later(repository_snapshot) render json: { html: render_to_string(partial: 'my_modules/repositories/full_view_version', @@ -108,7 +109,7 @@ class MyModuleRepositorySnapshotsController < ApplicationController Activities::CreateActivityService.call( activity_type: :export_inventory_snapshot_items_assigned_to_task, owner: current_user, - subject: @repository_snapshot, + subject: @my_module, team: current_team, message_items: { my_module: @my_module.id, diff --git a/app/controllers/my_module_status_flow_controller.rb b/app/controllers/my_module_status_flow_controller.rb new file mode 100644 index 000000000..972dcc6d4 --- /dev/null +++ b/app/controllers/my_module_status_flow_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class MyModuleStatusFlowController < ApplicationController + before_action :load_my_module + before_action :check_view_permissions + + def show + my_module_statuses = @my_module.my_module_status_flow + .my_module_statuses + .preload(:my_module_status_implications, next_status: :my_module_status_conditions) + .sort_by_position + render json: { html: render_to_string(partial: 'my_modules/modals/status_flow_modal_body.html.erb', + locals: { my_module_statuses: my_module_statuses }) } + end + + private + + def load_my_module + @my_module = MyModule.find_by(id: params[:my_module_id]) + render_404 unless @my_module + end + + def check_view_permissions + render_403 unless can_read_experiment?(@my_module.experiment) + end +end diff --git a/app/controllers/my_module_tags_controller.rb b/app/controllers/my_module_tags_controller.rb index ed8253bc4..789ea0931 100644 --- a/app/controllers/my_module_tags_controller.rb +++ b/app/controllers/my_module_tags_controller.rb @@ -3,7 +3,7 @@ class MyModuleTagsController < ApplicationController before_action :load_vars, except: :canvas_index before_action :check_view_permissions, only: :index - before_action :check_manage_permissions, only: %i(create index_edit destroy) + before_action :check_manage_permissions, only: %i(create index_edit destroy destroy_by_tag_id) def index_edit @my_module_tags = @my_module.my_module_tags.order(:id) @@ -155,11 +155,7 @@ class MyModuleTagsController < ApplicationController end def check_manage_permissions - render_403 unless can_manage_tags?(@my_module.experiment.project) - end - - def init_gui - @tags = @my_module.unassigned_tags + render_403 unless can_manage_module?(@my_module) end def mt_params diff --git a/app/controllers/my_modules_controller.rb b/app/controllers/my_modules_controller.rb index 378d86082..ca74d7de4 100644 --- a/app/controllers/my_modules_controller.rb +++ b/app/controllers/my_modules_controller.rb @@ -10,9 +10,8 @@ class MyModulesController < ApplicationController before_action :load_projects_tree, only: %i(protocols results activities archive) before_action :check_archive_and_restore_permissions, only: %i(update) before_action :check_manage_permissions, only: %i(description due_date update_description update_protocol_description) - before_action :check_view_permissions, except: %i(update update_description update_protocol_description - toggle_task_state) - before_action :check_complete_module_permission, only: %i(complete_my_module toggle_task_state) + before_action :check_view_permissions, except: %i(update update_description update_protocol_description) + before_action :check_update_state_permissions, only: :update_state before_action :set_inline_name_editing, only: %i(protocols results activities archive) layout 'fluid'.freeze @@ -45,6 +44,14 @@ class MyModulesController < ApplicationController end end + def status_state + respond_to do |format| + format.json do + render json: { status_changing: @my_module.status_changing? } + end + end + end + def activities params[:subjects] = { MyModule: [@my_module.id] @@ -126,6 +133,8 @@ class MyModulesController < ApplicationController log_activity(:restore_module) end else + render_403 && return unless can_manage_module?(@my_module) + saved = @my_module.save if saved if description_changed @@ -258,100 +267,23 @@ class MyModulesController < ApplicationController def archive @archived_results = @my_module.archived_results - current_team_switch(@my_module - .experiment - .project - .team) + current_team_switch(@my_module.experiment.project.team) end + def update_state + old_status_id = @my_module.my_module_status_id + if @my_module.update(my_module_status_id: update_status_params[:status_id]) + log_activity(:change_status_on_task_flow, @my_module, my_module_status_old: old_status_id, + my_module_status_new: @my_module.my_module_status.id) - # Complete/uncomplete task - def toggle_task_state - respond_to do |format| - @my_module.completed? ? @my_module.uncompleted! : @my_module.completed! - task_completion_activity - - # Render new button HTML - new_btn_partial = if @my_module.completed? - 'my_modules/state_button_uncomplete.html.erb' - else - 'my_modules/state_button_complete.html.erb' - end - - format.json do - render json: { - new_btn: render_to_string(partial: new_btn_partial), - completed: @my_module.completed?, - module_header_due_date: render_to_string( - partial: 'my_modules/module_header_due_date.html.erb', - locals: { my_module: @my_module } - ), - module_state_label: render_to_string( - partial: 'my_modules/module_state_label.html.erb', - locals: { my_module: @my_module } - ) - } - end - end - end - - def complete_my_module - respond_to do |format| - if @my_module.uncompleted? && @my_module.check_completness_status - @my_module.completed! - task_completion_activity - format.json do - render json: { - task_button_title: t('my_modules.buttons.uncomplete'), - module_header_due_date: render_to_string( - partial: 'my_modules/module_header_due_date.html.erb', - locals: { my_module: @my_module } - ), - module_state_label: render_to_string( - partial: 'my_modules/module_state_label.html.erb', - locals: { my_module: @my_module } - ) - }, status: :ok - end - else - format.json { render json: {}, status: :unprocessable_entity } - end + return redirect_to protocols_my_module_path(@my_module) + else + render json: { errors: @my_module.errors.messages.values.flatten.join('\n') }, status: :unprocessable_entity end end private - def task_completion_activity - completed = @my_module.completed? - log_activity(completed ? :complete_task : :uncomplete_task) - start_work_on_next_task_notification - end - - def start_work_on_next_task_notification - if @my_module.completed? - title = t('notifications.start_work_on_next_task', - user: current_user.full_name, - module: @my_module.name) - message = t('notifications.start_work_on_next_task_message', - project: link_to(@project.name, project_url(@project)), - experiment: link_to(@experiment.name, - canvas_experiment_url(@experiment)), - my_module: link_to(@my_module.name, - protocols_my_module_url(@my_module))) - notification = Notification.create( - type_of: :recent_changes, - title: sanitize_input(title, %w(strong a)), - message: sanitize_input(message, %w(strong a)), - generator_user_id: current_user.id - ) - # create notification for all users on the next modules in the workflow - @my_module.my_modules.map(&:users).flatten.uniq.each do |target_user| - next if target_user == current_user || !target_user.recent_notification - UserNotification.create(notification: notification, user: target_user) - end - end - end - def load_vars @my_module = MyModule.find_by_id(params[:id]) if @my_module @@ -384,8 +316,9 @@ class MyModulesController < ApplicationController render_403 unless can_read_experiment?(@my_module.experiment) end - def check_complete_module_permission - render_403 unless can_complete_module?(@my_module) + def check_update_state_permissions + return render_403 unless can_change_my_module_flow_status?(@my_module) + render_404 unless @my_module.my_module_status end def set_inline_name_editing @@ -414,6 +347,10 @@ class MyModulesController < ApplicationController update_params end + def update_status_params + params.require(:my_module).permit(:status_id) + end + def log_start_date_change_activity(start_date_changes) type_of = if start_date_changes[0].nil? # set started_on message_items = { my_module_started_on: @my_module.started_on } diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 955063fbd..6d33f8719 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -3,6 +3,7 @@ class ProjectsController < ApplicationController include TeamsHelper include InputSanitizeHelper + before_action :switch_team_with_param, only: :index before_action :load_vars, only: %i(show edit update notifications reports experiment_archive) @@ -34,7 +35,6 @@ class ProjectsController < ApplicationController } end format.html do - current_team_switch(Team.find_by_id(params[:team])) if params[:team] @teams = current_user.teams # New project for create new project modal @project = Project.new diff --git a/app/controllers/protocols_controller.rb b/app/controllers/protocols_controller.rb index 28f3a27e4..d649d539c 100644 --- a/app/controllers/protocols_controller.rb +++ b/app/controllers/protocols_controller.rb @@ -325,7 +325,7 @@ class ProtocolsController < ApplicationController @protocol.unlink rescue Exception transaction_error = true - raise ActiveRecord:: Rollback + raise ActiveRecord::Rollback end end @@ -353,13 +353,11 @@ class ProtocolsController < ApplicationController if @protocol.can_destroy? transaction_error = false Protocol.transaction do - begin - # Revert is basically update from parent - @protocol.update_from_parent(current_user) - rescue Exception - transaction_error = true - raise ActiveRecord:: Rollback - end + # Revert is basically update from parent + @protocol.update_from_parent(current_user) + rescue StandardError + transaction_error = true + raise ActiveRecord::Rollback end if transaction_error @@ -397,12 +395,10 @@ class ProtocolsController < ApplicationController if @protocol.parent.can_destroy? transaction_error = false Protocol.transaction do - begin - @protocol.update_parent(current_user) - rescue Exception - transaction_error = true - raise ActiveRecord:: Rollback - end + @protocol.update_parent(current_user) + rescue StandardError + transaction_error = true + raise ActiveRecord::Rollback end if transaction_error @@ -440,12 +436,10 @@ class ProtocolsController < ApplicationController if @protocol.can_destroy? transaction_error = false Protocol.transaction do - begin - @protocol.update_from_parent(current_user) - rescue Exception - transaction_error = true - raise ActiveRecord:: Rollback - end + @protocol.update_from_parent(current_user) + rescue StandardError + transaction_error = true + raise ActiveRecord::Rollback end if transaction_error @@ -483,12 +477,10 @@ class ProtocolsController < ApplicationController if @protocol.can_destroy? transaction_error = false Protocol.transaction do - begin - @protocol.load_from_repository(@source, current_user) - rescue Exception - transaction_error = true - raise ActiveRecord:: Rollback - end + @protocol.load_from_repository(@source, current_user) + rescue StandardError + transaction_error = true + raise ActiveRecord::Rollback end if transaction_error @@ -1140,7 +1132,7 @@ class ProtocolsController < ApplicationController @source = Protocol.find_by_id(params[:source_id]) render_403 unless @protocol.present? && @source.present? && - (can_manage_protocol_in_module?(@protocol) || + (can_manage_protocol_in_module?(@protocol) && can_read_protocol_in_repository?(@source)) end diff --git a/app/controllers/steps_controller.rb b/app/controllers/steps_controller.rb index ddec42b89..820044161 100644 --- a/app/controllers/steps_controller.rb +++ b/app/controllers/steps_controller.rb @@ -10,7 +10,7 @@ class StepsController < ApplicationController before_action :convert_table_contents_to_utf8, only: %i(create update) before_action :check_view_permissions, only: %i(show update_view_state) - before_action :check_manage_permissions, only: %i(new create edit update destroy move_up move_down) + before_action :check_manage_permissions, only: %i(new create edit update destroy move_up move_down toggle_step_state) before_action :check_complete_and_checkbox_permissions, only: %i(toggle_step_state checklistitem_state) def new @@ -307,10 +307,6 @@ class StepsController < ApplicationController @step.completed = completed if @step.save - if @protocol.in_module? - ready_to_complete = @protocol.my_module.check_completness_status - end - # Create activity if changed completed_steps = @protocol.steps.where(completed: true).count @@ -336,14 +332,7 @@ class StepsController < ApplicationController t('protocols.steps.options.uncomplete_title') end format.json do - if ready_to_complete && @protocol.my_module.uncompleted? - render json: { - task_ready_to_complete: true, - new_title: localized_title - }, status: :ok - else - render json: { new_title: localized_title }, status: :ok - end + render json: { new_title: localized_title }, status: :ok end else format.json { render json: {}, status: :unprocessable_entity } @@ -354,16 +343,11 @@ class StepsController < ApplicationController def move_up respond_to do |format| format.json do - if @step.protocol.steps.minimum(:position) != @step.position - @step.update!(position: @step.position - 1) + @step.move_up - render json: { - step_up_position: @step.position, - step_down_position: @step.position + 1 - } - else - render json: {} - end + render json: { + steps_order: @protocol.steps.order(:position).select(:id, :position) + } end end end @@ -371,16 +355,11 @@ class StepsController < ApplicationController def move_down respond_to do |format| format.json do - if @step.protocol.steps.maximum(:position) != @step.position - @step.update!(position: @step.position + 1) + @step.move_down - render json: { - step_up_position: @step.position - 1, - step_down_position: @step.position - } - else - render json: {} - end + render json: { + steps_order: @protocol.steps.order(:position).select(:id, :position) + } end end end @@ -490,6 +469,7 @@ class StepsController < ApplicationController item_record = ck.checklist_items.find_by(id: item[1][:id]) next unless item_record + item_record.update_attribute('position', item[1][:position]) end end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 6fb0cb8b7..756ca32c4 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -2,6 +2,7 @@ class TagsController < ApplicationController before_action :load_vars, only: [:create, :update, :destroy] before_action :load_vars_nested, only: [:update, :destroy] before_action :check_manage_permissions, only: %i(create update destroy) + before_action :check_manage_my_module_permissions, only: %i(create) def create @tag = Tag.new(tag_params) @@ -153,6 +154,12 @@ class TagsController < ApplicationController end end + def check_manage_my_module_permissions + my_module = MyModule.find_by id: params[:my_module_id] + + render_403 if my_module && !can_manage_module?(my_module) + end + def check_manage_permissions render_403 unless can_manage_tags?(@project) end diff --git a/app/controllers/user_my_modules_controller.rb b/app/controllers/user_my_modules_controller.rb index c7d4d2269..2a6cbf97b 100644 --- a/app/controllers/user_my_modules_controller.rb +++ b/app/controllers/user_my_modules_controller.rb @@ -121,10 +121,6 @@ class UserMyModulesController < ApplicationController render_403 unless can_manage_users_in_module?(@my_module) end - def init_gui - @users = @my_module.unassigned_users - end - def um_params params.require(:user_my_module).permit(:user_id, :my_module_id) end diff --git a/app/controllers/user_projects_controller.rb b/app/controllers/user_projects_controller.rb index ac29ddfbd..b469dada1 100644 --- a/app/controllers/user_projects_controller.rb +++ b/app/controllers/user_projects_controller.rb @@ -3,7 +3,7 @@ class UserProjectsController < ApplicationController include InputSanitizeHelper before_action :load_vars - before_action :load_up_var, only: %i(update destroy) + before_action :load_user_project, only: %i(update destroy) before_action :check_view_permissions, only: :index before_action :check_manage_users_permissions, only: :index_edit before_action :check_create_permissions, only: :create @@ -26,9 +26,9 @@ class UserProjectsController < ApplicationController end def index_edit - @users = @project.user_projects + @user_projects = @project.user_projects @unassigned_users = @project.unassigned_users - @up = UserProject.new(project: @project) + @new_user_project = UserProject.new(project: @project) respond_to do |format| format.json do @@ -48,10 +48,10 @@ class UserProjectsController < ApplicationController end def create - @up = UserProject.new(up_params.merge(project: @project)) - @up.assigned_by = current_user + @user_project = @project.user_projects.new(user_project_params) + @user_project.assigned_by = current_user - if @up.save + if @user_project.save log_activity(:assign_user_to_project) respond_to do |format| @@ -61,23 +61,23 @@ class UserProjectsController < ApplicationController end else error = t('user_projects.create.can_add_user_to_project') - error = t('user_projects.create.select_user_role') unless @up.role + error = t('user_projects.create.select_user_role') unless @user_project.role respond_to do |format| - format.json { - render :json => { + format.json do + render json: { status: 'error', error: error } - } + end end end end def update - @up.role = up_params[:role] + @user_project.role = user_project_params[:role] - if @up.save + if @user_project.save log_activity(:change_user_role_on_project) respond_to do |format| @@ -90,7 +90,7 @@ class UserProjectsController < ApplicationController format.json do render json: { status: 'error', - errors: @up.errors + errors: @user_project.errors } end end @@ -98,20 +98,20 @@ class UserProjectsController < ApplicationController end def destroy - if @up.destroy + if @user_project.destroy log_activity(:unassign_user_from_project) respond_to do |format| format.json do redirect_to project_users_edit_path(format: :json), turbolinks: false, - status: 303 + status: :see_other end end else respond_to do |format| format.json do render json: { - errors: @up.errors + errors: @user_project.errors } end end @@ -121,13 +121,13 @@ class UserProjectsController < ApplicationController private def load_vars - @project = Project.find_by_id(params[:project_id]) + @project = Project.find_by(id: params[:project_id]) render_404 unless @project end - def load_up_var - @up = UserProject.find(params[:id]) - render_404 unless @up + def load_user_project + @user_project = @project.user_projects.find(params[:id]) + render_404 unless @user_project end def check_view_permissions @@ -139,19 +139,14 @@ class UserProjectsController < ApplicationController end def check_create_permissions - render_403 unless can_create_projects?(current_team) + render_403 unless can_manage_project?(@project) end def check_manage_permissions - render_403 unless can_manage_project?(@project) && - @up.user_id != current_user.id + render_403 unless can_manage_project?(@project) && @user_project.user_id != current_user.id end - def init_gui - @users = @project.unassigned_users - end - - def up_params + def user_project_params params.require(:user_project).permit(:user_id, :project_id, :role) end @@ -163,7 +158,7 @@ class UserProjectsController < ApplicationController team: @project.team, project: @project, message_items: { project: @project.id, - user_target: @up.user.id, - role: @up.role_str }) + user_target: @user_project.user.id, + role: @user_project.role_str }) end end diff --git a/app/helpers/experiments_helper.rb b/app/helpers/experiments_helper.rb new file mode 100644 index 000000000..be88c0a13 --- /dev/null +++ b/app/helpers/experiments_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ExperimentsHelper + def grouped_by_prj(experiments) + ungrouped_experiments = experiments.joins(:project) + .select('projects.name as project_name, + projects.archived as project_archived, + experiments.*') + ungrouped_experiments.group_by { |i| [i[:project_name]] }.map do |group, exps| + { + project_name: group[0], + project_archived: exps[0]&.project_archived, + experiments: exps + } + end + end +end diff --git a/app/helpers/my_modules_helper.rb b/app/helpers/my_modules_helper.rb index 6b7f67ae4..e2e6e7c30 100644 --- a/app/helpers/my_modules_helper.rb +++ b/app/helpers/my_modules_helper.rb @@ -31,11 +31,9 @@ module MyModulesHelper def get_task_alert_color(my_module) alert = '' - if !my_module.completed? + unless my_module.completed? alert = ' alert-yellow' if my_module.is_one_day_prior? alert = ' alert-red' if my_module.is_overdue? - elsif my_module.completed? - alert = ' alert-green' end alert end diff --git a/app/helpers/reports_helper.rb b/app/helpers/reports_helper.rb index b442f1b56..23f96ffc3 100644 --- a/app/helpers/reports_helper.rb +++ b/app/helpers/reports_helper.rb @@ -154,7 +154,7 @@ module ReportsHelper style = 'default' text = t('protocols.steps.uncompleted') end - "#{text}".html_safe + "[#{text}]".html_safe end # Fixes issues with avatar images in reports diff --git a/app/helpers/teams_helper.rb b/app/helpers/teams_helper.rb index 9c5d116bb..c5e69bfb1 100644 --- a/app/helpers/teams_helper.rb +++ b/app/helpers/teams_helper.rb @@ -1,9 +1,10 @@ module TeamsHelper # resets the current team if needed def current_team_switch(team) - if team != current_team + if team != current_team && current_user.is_member_of_team?(team) current_user.current_team_id = team.id current_user.save + update_current_team end end @@ -17,11 +18,7 @@ module TeamsHelper end end - def team_created_by(team) - User.find_by_id(team.created_by_id) - end - def switch_team_with_param - current_team_switch(Team.find_by(id: params[:team])) if params[:team] + current_team_switch(current_user.teams.find_by(id: params[:team])) if params[:team] end end diff --git a/app/javascript/packs/fonts.js b/app/javascript/packs/fonts.js new file mode 100644 index 000000000..9147247fb --- /dev/null +++ b/app/javascript/packs/fonts.js @@ -0,0 +1 @@ +require('typeface-lato'); diff --git a/app/jobs/active_storage/preview_job.rb b/app/jobs/active_storage/preview_job.rb index f1eda5812..2d1941ca6 100644 --- a/app/jobs/active_storage/preview_job.rb +++ b/app/jobs/active_storage/preview_job.rb @@ -6,7 +6,9 @@ class ActiveStorage::PreviewJob < ActiveStorage::BaseJob discard_on StandardError do |job, error| blob = ActiveStorage::Blob.find_by(id: job.arguments.first) - blob&.attachments&.take&.record&.update(file_processing: false) + ActiveRecord::Base.no_touching do + blob&.attachments&.take&.record&.update(file_processing: false) + end Rails.logger.error "Couldn't generate preview for Blob with id: #{job.arguments.first}. Error:\n #{error}" end @@ -24,6 +26,8 @@ class ActiveStorage::PreviewJob < ActiveStorage::BaseJob Rails.logger.info "Preview for the Blod with id: #{blob.id} - successfully generated.\n" \ "Transformations applied: #{preview.variation.transformations}" - blob.attachments.take.record.update(file_processing: false) + ActiveRecord::Base.no_touching do + blob.attachments.take.record.update(file_processing: false) + end end end diff --git a/app/jobs/my_module_status_consequences_job.rb b/app/jobs/my_module_status_consequences_job.rb new file mode 100644 index 000000000..aa0a4d37c --- /dev/null +++ b/app/jobs/my_module_status_consequences_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class MyModuleStatusConsequencesJob < ApplicationJob + queue_as :high_priority + + def perform(my_module, my_module_status_consequences) + error_raised = false + my_module.transaction do + my_module_status_consequences.each do |consequence| + consequence.call(my_module) + end + my_module.update!(status_changing: false) + rescue StandardError => e + Rails.logger.error(e.message) + Rails.logger.error(e.backtrace.join("\n")) + error_raised = true + end + if error_raised + my_module.my_module_status = my_module.changing_from_my_module_status + my_module.status_changing = false + my_module.save! + end + end +end diff --git a/app/models/asset.rb b/app/models/asset.rb index f6ec993ec..ea036f300 100644 --- a/app/models/asset.rb +++ b/app/models/asset.rb @@ -32,17 +32,15 @@ class Asset < ApplicationRecord optional: true belongs_to :team, optional: true has_one :step_asset, inverse_of: :asset, dependent: :destroy - has_one :step, through: :step_asset, dependent: :nullify + has_one :step, through: :step_asset, touch: true, dependent: :nullify has_one :result_asset, inverse_of: :asset, dependent: :destroy - has_one :result, through: :result_asset, dependent: :nullify + has_one :result, through: :result_asset, touch: true, dependent: :nullify has_one :repository_asset_value, inverse_of: :asset, dependent: :destroy has_one :repository_cell, through: :repository_asset_value, dependent: :nullify has_many :report_elements, inverse_of: :asset, dependent: :destroy has_one :asset_text_datum, inverse_of: :asset, dependent: :destroy - after_save { result&.touch; step&.touch } - attr_accessor :file_content, :file_info, :in_template def self.search( @@ -222,8 +220,7 @@ class Asset < ApplicationRecord Rails.logger.info "Asset #{id}: Creating extract text job" # The extract_asset_text also includes # estimated size calculation - Asset.delay(queue: :assets, run_at: 20.minutes.from_now) - .extract_asset_text_delayed(id, in_template) + Asset.delay(queue: :assets).extract_asset_text_delayed(id, in_template) elsif marvinjs? extract_asset_text else diff --git a/app/models/concerns/archivable_model.rb b/app/models/concerns/archivable_model.rb index d0ecef2df..fdde1757c 100644 --- a/app/models/concerns/archivable_model.rb +++ b/app/models/concerns/archivable_model.rb @@ -15,7 +15,7 @@ module ArchivableModel # Helper for archiving project. Timestamp of archiving is handler by # before_save callback. # Sets the archived_by value to the current user. - def archive (current_user) + def archive(current_user) self.archived = true self.archived_by = current_user save @@ -29,7 +29,7 @@ module ArchivableModel # Helper for restoring project from archive. # Sets the restored_by value to the current user. - def restore (current_user) + def restore(current_user) self.archived = false self.restored_by = current_user save diff --git a/app/models/concerns/searchable_by_name_model.rb b/app/models/concerns/searchable_by_name_model.rb index 88a9f66f2..f0f78cff4 100644 --- a/app/models/concerns/searchable_by_name_model.rb +++ b/app/models/concerns/searchable_by_name_model.rb @@ -2,20 +2,30 @@ module SearchableByNameModel extend ActiveSupport::Concern - + # rubocop:disable Metrics/BlockLength included do def self.search_by_name(user, teams = [], query = nil, options = {}) return if user.blank? || teams.blank? - viewable_by_user(user, teams) - .where_attributes_like("#{table_name}.name", query, options) - .limit(Constants::SEARCH_LIMIT) + sql_q = viewable_by_user(user, teams) + + if options[:intersect] + query_array = query.gsub(/[[:space:]]+/, ' ').split(' ') + query_array.each do |string| + sql_q = sql_q.where("trim_html_tags(#{table_name}.name) ILIKE ?", "%#{string}%") + end + else + sql_q = sql_q.where_attributes_like("#{table_name}.name", query, options) + end + + sql_q.limit(Constants::SEARCH_LIMIT) end def self.filter_by_teams(teams = []) return self if teams.empty? + if column_names.include? 'team_id' - return where(team_id: teams) + where(team_id: teams) else valid_subjects = Extends::ACTIVITY_SUBJECT_CHILDREN parent_array = [to_s.underscore] @@ -38,8 +48,9 @@ module SearchableByNameModel query = child.to_s.camelize.constantize.where("#{last_parent}_id" => query) last_parent = child end - return where("#{last_parent}_id" => query) + where("#{last_parent}_id" => query) end end end + # rubocop:enable Metrics/BlockLength end diff --git a/app/models/experiment.rb b/app/models/experiment.rb index 9d15a4d35..058f28bd2 100644 --- a/app/models/experiment.rb +++ b/app/models/experiment.rb @@ -248,12 +248,6 @@ class Experiment < ApplicationRecord private - # Archive all modules. Receives an array of module integer IDs. - def archive_modules(module_ids) - my_modules.where(id: module_ids).each(&:archive!) - my_modules.reload - end - # Archive all modules. Receives an array of module integer IDs # and current user. def archive_modules(module_ids, current_user) diff --git a/app/models/my_module.rb b/app/models/my_module.rb index 142c9e33a..6beac9652 100644 --- a/app/models/my_module.rb +++ b/app/models/my_module.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class MyModule < ApplicationRecord include ArchivableModel include SearchableModel @@ -7,9 +9,11 @@ class MyModule < ApplicationRecord enum state: Extends::TASKS_STATES before_create :create_blank_protocol - before_validation :set_completed_on, if: :state_changed? + before_create :assign_default_status_flow - auto_strip_attributes :name, :description, nullify: false + around_save :exec_status_consequences, if: :my_module_status_id_changed? + + auto_strip_attributes :name, :description, nullify: false, if: proc { |mm| mm.name_changed? || mm.description_changed? } validates :name, length: { minimum: Constants::NAME_MIN_LENGTH, maximum: Constants::NAME_MAX_LENGTH } @@ -20,12 +24,19 @@ class MyModule < ApplicationRecord validate :coordinates_uniqueness_check, if: :active? validates :completed_on, presence: true, if: proc { |mm| mm.completed? } + validate :check_status, if: :my_module_status_id_changed? + validate :check_status_conditions, if: :my_module_status_id_changed? + validate :check_status_implications + belongs_to :created_by, foreign_key: 'created_by_id', class_name: 'User', optional: true belongs_to :last_modified_by, foreign_key: 'last_modified_by_id', class_name: 'User', optional: true belongs_to :archived_by, foreign_key: 'archived_by_id', class_name: 'User', optional: true belongs_to :restored_by, foreign_key: 'restored_by_id', class_name: 'User', optional: true belongs_to :experiment, inverse_of: :my_modules, touch: true belongs_to :my_module_group, inverse_of: :my_modules, optional: true + belongs_to :my_module_status, optional: true + belongs_to :changing_from_my_module_status, optional: true, class_name: 'MyModuleStatus' + delegate :my_module_status_flow, to: :my_module_status, allow_nil: true has_many :results, inverse_of: :my_module, dependent: :destroy has_many :my_module_tags, inverse_of: :my_module, dependent: :destroy has_many :tags, through: :my_module_tags @@ -55,16 +66,6 @@ class MyModule < ApplicationRecord end) scope :workflow_ordered, -> { order(workflow_order: :asc) } scope :uncomplete, -> { where(state: 'uncompleted') } - scope :with_step_statistics, (lambda do - left_outer_joins(protocols: :steps) - .group(:id) - .select('my_modules.*') - .select('COUNT(steps.id) AS steps_total') - .select('COUNT(steps.id) FILTER (where steps.completed = true) AS steps_completed') - .select('CASE COUNT(steps.id) WHEN 0 THEN 0 ELSE'\ - '((COUNT(steps.id) FILTER (where steps.completed = true)) * 100 / COUNT(steps.id)) '\ - 'END AS steps_completed_percentage') - end) # A module takes this much space in canvas (x, y) in database WIDTH = 30 @@ -139,14 +140,16 @@ class MyModule < ApplicationRecord # Remove association with module group. self.my_module_group = nil + was_archived = false + MyModule.transaction do - archived = super + was_archived = super # Remove all connection between modules. - archived = Connection.where(input_id: id).delete_all if archived - archived = Connection.where(output_id: id).delete_all if archived - raise ActiveRecord::Rollback unless archived + was_archived = Connection.where(input_id: id).destroy_all if was_archived + was_archived = Connection.where(output_id: id).destroy_all if was_archived + raise ActiveRecord::Rollback unless was_archived end - archived + was_archived end # Similar as super restore, but also calculate new module position @@ -393,6 +396,8 @@ class MyModule < ApplicationRecord clone.save! + clone.assign_user(current_user) + # Remove the automatically generated protocol, # & clone the protocol instead clone.protocol.destroy @@ -436,18 +441,6 @@ class MyModule < ApplicationRecord { x: 0, y: positions.last[1] + HEIGHT } end - # Check if my_module is ready to become completed - def check_completness_status - if protocol && protocol.steps.count > 0 - completed = true - protocol.steps.find_each do |step| - completed = false unless step.completed - end - return true if completed - end - false - end - def assign_user(user, assigned_by = nil) user_my_modules.create( assigned_by: assigned_by || user, @@ -465,12 +458,6 @@ class MyModule < ApplicationRecord private - def set_completed_on - return if completed? && completed_on.present? - - self.completed_on = completed? ? DateTime.now : nil - end - def create_blank_protocol protocols << Protocol.new_blank_for_module(self) end @@ -480,4 +467,54 @@ class MyModule < ApplicationRecord errors.add(:position, I18n.t('activerecord.errors.models.my_module.attributes.position.not_unique')) end end + + def assign_default_status_flow + return if my_module_status.present? || MyModuleStatusFlow.global.blank? + + self.my_module_status = MyModuleStatusFlow.global.first.initial_status + end + + def check_status_conditions + return if my_module_status.blank? + + my_module_status.my_module_status_conditions.each do |condition| + condition.call(self) + end + end + + def check_status_implications + return if my_module_status.blank? + + my_module_status.my_module_status_implications.each do |implication| + implication.call(self) + end + end + + def check_status + return unless my_module_status_id_was + + original_status = MyModuleStatus.find_by(id: my_module_status_id_was) + unless my_module_status && [original_status.next_status, original_status.previous_status].include?(my_module_status) + errors.add(:my_module_status_id, + I18n.t('activerecord.errors.models.my_module.attributes.my_module_status_id.not_correct_order')) + end + end + + def exec_status_consequences + return if my_module_status.blank? || status_changing + + self.changing_from_my_module_status_id = my_module_status_id_was if my_module_status_id_was.present? + self.status_changing = true + + yield + + if my_module_status.my_module_status_consequences.any?(&:runs_in_background?) + MyModuleStatusConsequencesJob.perform_later(self, my_module_status.my_module_status_consequences.to_a) + else + my_module_status.my_module_status_consequences.each do |consequence| + consequence.call(self) + end + update!(status_changing: false) + end + end end diff --git a/app/models/my_module_status.rb b/app/models/my_module_status.rb new file mode 100644 index 000000000..f12115e5a --- /dev/null +++ b/app/models/my_module_status.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class MyModuleStatus < ApplicationRecord + has_many :my_modules, dependent: :nullify + has_many :my_module_status_conditions, dependent: :destroy + has_many :my_module_status_consequences, dependent: :destroy + has_many :my_module_status_implications, dependent: :destroy + belongs_to :my_module_status_flow + belongs_to :created_by, class_name: 'User', optional: true + belongs_to :last_modified_by, class_name: 'User', optional: true + has_one :next_status, class_name: 'MyModuleStatus', + foreign_key: 'previous_status_id', + inverse_of: :previous_status, + dependent: :nullify + belongs_to :previous_status, class_name: 'MyModuleStatus', inverse_of: :next_status, optional: true + + validates :name, presence: true, length: { minimum: Constants::NAME_MIN_LENGTH, maximum: Constants::NAME_MAX_LENGTH } + validates :color, presence: true + validates :description, length: { maximum: Constants::TEXT_MAX_LENGTH } + validates :next_status, uniqueness: true, if: -> { next_status.present? } + validates :previous_status, uniqueness: true, if: -> { previous_status.present? } + validate :next_in_same_flow, if: -> { next_status.present? } + validate :previous_in_same_flow, if: -> { previous_status.present? } + + def initial_status? + my_module_status_flow.initial_status == self + end + + def final_status? + my_module_status_flow.final_status == self + end + + def self.sort_by_position(order = :asc) + ordered_statuses, statuses = all.to_a.partition { |i| i.previous_status_id.nil? } + + return [] if ordered_statuses.empty? + + until statuses.empty? + next_element, statuses = statuses.partition { |i| ordered_statuses.last.id == i.previous_status_id } + if next_element.empty? + break + else + ordered_statuses.concat(next_element) + end + end + ordered_statuses = ordered_statuses.reverse if order == :desc + ordered_statuses + end + + def conditions_errors(my_module) + mm_copy = my_module.clone + mm_copy.errors.clear + + my_module_status_conditions.each do |condition| + condition.call(mm_copy) + end + + mm_copy.errors.messages&.values&.flatten + end + + private + + def next_in_same_flow + errors.add(:next_status, :different_flow) unless next_status.my_module_status_flow == my_module_status_flow + end + + def previous_in_same_flow + errors.add(:previous_status, :different_flow) unless previous_status.my_module_status_flow == my_module_status_flow + end +end diff --git a/app/models/my_module_status_condition.rb b/app/models/my_module_status_condition.rb new file mode 100644 index 000000000..0b94d3dbc --- /dev/null +++ b/app/models/my_module_status_condition.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MyModuleStatusCondition < ApplicationRecord + belongs_to :my_module_status + + def description + '' + end +end diff --git a/app/models/my_module_status_conditions/active.rb b/app/models/my_module_status_conditions/active.rb new file mode 100644 index 000000000..68c00aa7f --- /dev/null +++ b/app/models/my_module_status_conditions/active.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Just an example, to be replaced with an actual implementation +module MyModuleStatusConditions + class Active < MyModuleStatusCondition + def call(my_module) + my_module.errors.add(:status_conditions, I18n.t('my_module_statuses.conditions.error.my_module_not_active')) unless my_module.active? + end + + def description + I18n.t('my_module_statuses.conditions.error.my_module_not_active') + end + end +end diff --git a/app/models/my_module_status_consequence.rb b/app/models/my_module_status_consequence.rb new file mode 100644 index 000000000..5ed34193e --- /dev/null +++ b/app/models/my_module_status_consequence.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MyModuleStatusConsequence < ApplicationRecord + belongs_to :my_module_status + + def runs_in_background? + false + end +end diff --git a/app/models/my_module_status_consequences/change_activity.rb b/app/models/my_module_status_consequences/change_activity.rb new file mode 100644 index 000000000..3bc736f4d --- /dev/null +++ b/app/models/my_module_status_consequences/change_activity.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Just an example, to be replaced with an actual implementation +module MyModuleStatusConsequences + class ChangeActivity < MyModuleStatusConsequence + def call(my_module) + # Create new activity here + puts "State changed to #{my_module_status.name}} for #{my_module.name}" + end + end +end diff --git a/app/models/my_module_status_consequences/completion.rb b/app/models/my_module_status_consequences/completion.rb new file mode 100644 index 000000000..4111f045c --- /dev/null +++ b/app/models/my_module_status_consequences/completion.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Just an example, to be replaced with an actual implementation +module MyModuleStatusConsequences + class Completion < MyModuleStatusConsequence + def call(my_module) + my_module.state = 'completed' + my_module.completed_on = DateTime.now + my_module.save! + end + end +end diff --git a/app/models/my_module_status_consequences/repository_snapshot.rb b/app/models/my_module_status_consequences/repository_snapshot.rb new file mode 100644 index 000000000..090700f13 --- /dev/null +++ b/app/models/my_module_status_consequences/repository_snapshot.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module MyModuleStatusConsequences + class RepositorySnapshot < MyModuleStatusConsequence + def runs_in_background? + true + end + + def call(my_module) + my_module.assigned_repositories.each do |repository| + repository_snapshot = ::RepositorySnapshot.create_preliminary(repository, my_module) + service = Repositories::SnapshotProvisioningService.call(repository_snapshot: repository_snapshot) + unless service.succeed? + repository_snapshot.failed! + raise StandardError, service.errors + end + end + end + end +end diff --git a/app/models/my_module_status_consequences/uncompletion.rb b/app/models/my_module_status_consequences/uncompletion.rb new file mode 100644 index 000000000..4d3a5e184 --- /dev/null +++ b/app/models/my_module_status_consequences/uncompletion.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Just an example, to be replaced with an actual implementation +module MyModuleStatusConsequences + class Uncompletion < MyModuleStatusConsequence + def call(my_module) + return unless my_module.state == 'completed' + + my_module.state = 'uncompleted' + my_module.completed_on = nil + my_module.save! + end + end +end diff --git a/app/models/my_module_status_flow.rb b/app/models/my_module_status_flow.rb new file mode 100644 index 000000000..2cd6e422d --- /dev/null +++ b/app/models/my_module_status_flow.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class MyModuleStatusFlow < ApplicationRecord + enum visibility: { global: 0, in_team: 1 } + + has_many :my_module_statuses, dependent: :destroy + belongs_to :team, optional: true + belongs_to :created_by, class_name: 'User', optional: true + belongs_to :last_modified_by, class_name: 'User', optional: true + + validates :visibility, presence: true + validates :team, presence: true, if: :in_team? + validates :name, uniqueness: { scope: :team_id, case_sensitive: false }, if: :in_team? + validates :name, presence: true, length: { minimum: Constants::NAME_MIN_LENGTH, maximum: Constants::NAME_MAX_LENGTH } + validates :description, length: { maximum: Constants::TEXT_MAX_LENGTH } + + def initial_status + my_module_statuses.find_by(previous_status: nil) + end + + def final_status + my_module_statuses.left_outer_joins(:next_status).find_by('next_statuses_my_module_statuses.id': nil) + end + + def self.ensure_default + return if MyModuleStatusFlow.global.any? + + status_flow = MyModuleStatusFlow.create!(name: Extends::DEFAULT_FLOW_NAME, visibility: :global) + prev_id = nil + Extends::DEFAULT_FLOW_STATUSES.each do |status| + new_status = MyModuleStatus.create!(my_module_status_flow: status_flow, + name: status[:name], + color: status[:color], + previous_status_id: prev_id) + prev_id = new_status.id + + status[:conditions]&.each { |condition| condition.constantize.create!(my_module_status: new_status) } + status[:implications]&.each { |implication| implication.constantize.create!(my_module_status: new_status) } + status[:consequences]&.each { |consequence| consequence.constantize.create!(my_module_status: new_status) } + end + end +end diff --git a/app/models/my_module_status_implication.rb b/app/models/my_module_status_implication.rb new file mode 100644 index 000000000..96d4256bd --- /dev/null +++ b/app/models/my_module_status_implication.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MyModuleStatusImplication < ApplicationRecord + belongs_to :my_module_status + + def description + '' + end +end diff --git a/app/models/my_module_status_implications/read_only.rb b/app/models/my_module_status_implications/read_only.rb new file mode 100644 index 000000000..df9c5a2a2 --- /dev/null +++ b/app/models/my_module_status_implications/read_only.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Just an example, to be replaced with an actual implementation +module MyModuleStatusImplications + class ReadOnly < MyModuleStatusImplication + def call(my_module) + my_module.errors.add(:status_implication, 'Is read only') + false + end + end +end diff --git a/app/models/protocol.rb b/app/models/protocol.rb index 88f8b6e99..4ddafea6a 100644 --- a/app/models/protocol.rb +++ b/app/models/protocol.rb @@ -229,14 +229,16 @@ class Protocol < ApplicationRecord # Deep-clone given array of assets def self.deep_clone_assets(assets_to_clone) - assets_to_clone.each do |src_id, dest_id| - src = Asset.find_by(id: src_id) - dest = Asset.find_by(id: dest_id) - dest.destroy! if src.blank? && dest.present? - next unless src.present? && dest.present? + ActiveRecord::Base.no_touching do + assets_to_clone.each do |src_id, dest_id| + src = Asset.find_by(id: src_id) + dest = Asset.find_by(id: dest_id) + dest.destroy! if src.blank? && dest.present? + next unless src.present? && dest.present? - # Clone file - src.duplicate_file(dest) + # Clone file + src.duplicate_file(dest) + end end end @@ -524,12 +526,14 @@ class Protocol < ApplicationRecord end def update_parent(current_user) - # First, destroy parent's step contents - parent.destroy_contents - parent.reload + ActiveRecord::Base.no_touching do + # First, destroy parent's step contents + parent.destroy_contents + parent.reload - # Now, clone step contents - Protocol.clone_contents(self, parent, current_user, false) + # Now, clone step contents + Protocol.clone_contents(self, parent, current_user, false) + end # Lastly, update the metadata parent.reload @@ -542,11 +546,13 @@ class Protocol < ApplicationRecord end def update_from_parent(current_user) - # First, destroy step contents - destroy_contents + ActiveRecord::Base.no_touching do + # First, destroy step contents + destroy_contents - # Now, clone parent's step contents - Protocol.clone_contents(parent, self, current_user, false) + # Now, clone parent's step contents + Protocol.clone_contents(parent, self, current_user, false) + end # Lastly, update the metadata reload @@ -558,11 +564,13 @@ class Protocol < ApplicationRecord end def load_from_repository(source, current_user) - # First, destroy step contents - destroy_contents + ActiveRecord::Base.no_touching do + # First, destroy step contents + destroy_contents - # Now, clone source's step contents - Protocol.clone_contents(source, self, current_user, false) + # Now, clone source's step contents + Protocol.clone_contents(source, self, current_user, false) + end # Lastly, update the metadata reload @@ -588,12 +596,14 @@ class Protocol < ApplicationRecord # Don't proceed further if clone is invalid return clone if clone.invalid? - # Okay, clone seems to be valid: let's clone it - clone = deep_clone(clone, current_user) + ActiveRecord::Base.no_touching do + # Okay, clone seems to be valid: let's clone it + clone = deep_clone(clone, current_user) - # If the above operation went well, update published_on - # timestamp - clone.update(published_on: Time.now) if clone.in_repository_public? + # If the above operation went well, update published_on + # timestamp + clone.update(published_on: Time.zone.now) if clone.in_repository_public? + end # Link protocols if neccesary if link_protocols @@ -659,7 +669,7 @@ class Protocol < ApplicationRecord def destroy_contents # Calculate total space taken by the protocol st = space_taken - steps.destroy_all + steps.order(position: :desc).destroy_all # Release space taken by the step team.release_space(st) diff --git a/app/models/repository.rb b/app/models/repository.rb index 0df694b54..f09d6c874 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -142,7 +142,7 @@ class Repository < RepositoryBase end def self.viewable_by_user(_user, teams) - where(team: teams) + accessible_by_teams(teams) end def self.name_like(query) @@ -209,21 +209,6 @@ class Repository < RepositoryBase importer.run end - def provision_snapshot(my_module, created_by = nil) - created_by ||= self.created_by - repository_snapshot = dup.becomes(RepositorySnapshot) - repository_snapshot.assign_attributes(type: RepositorySnapshot.name, - original_repository: self, - my_module: my_module, - created_by: created_by, - team: my_module.experiment.project.team, - permission_level: Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:not_shared]) - repository_snapshot.provisioning! - repository_snapshot.reload - RepositorySnapshotProvisioningJob.perform_later(repository_snapshot) - repository_snapshot - end - def assigned_rows(my_module) repository_rows.joins(:my_module_repository_rows).where(my_module_repository_rows: { my_module_id: my_module.id }) end diff --git a/app/models/repository_checklist_value.rb b/app/models/repository_checklist_value.rb index cd09c9f1a..d3ab9e1cf 100644 --- a/app/models/repository_checklist_value.rb +++ b/app/models/repository_checklist_value.rb @@ -75,6 +75,8 @@ class RepositoryChecklistValue < ApplicationRecord end def self.import_from_text(text, attributes, _options = {}) + return nil if text.blank? + value = new(attributes) column = attributes.dig(:repository_cell_attributes, :repository_column) RepositoryImportParser::Util.split_by_delimiter(text: text, delimiter: column.delimiter_char).each do |item_text| diff --git a/app/models/repository_list_value.rb b/app/models/repository_list_value.rb index b99b08345..44392bb32 100644 --- a/app/models/repository_list_value.rb +++ b/app/models/repository_list_value.rb @@ -66,6 +66,8 @@ class RepositoryListValue < ApplicationRecord end def self.import_from_text(text, attributes, _options = {}) + return nil if text.blank? + value = new(attributes) column = attributes.dig(:repository_cell_attributes, :repository_column) list_item = column.repository_list_items.find { |item| item.data == text } diff --git a/app/models/repository_snapshot.rb b/app/models/repository_snapshot.rb index f29ad80d4..5107451a6 100644 --- a/app/models/repository_snapshot.rb +++ b/app/models/repository_snapshot.rb @@ -26,6 +26,19 @@ class RepositorySnapshot < RepositoryBase .order(:parent_id, updated_at: :desc) } + def self.create_preliminary(repository, my_module, created_by = nil) + created_by ||= repository.created_by + repository_snapshot = repository.dup.becomes(RepositorySnapshot) + repository_snapshot.assign_attributes(type: RepositorySnapshot.name, + original_repository: repository, + my_module: my_module, + created_by: created_by, + team: my_module.experiment.project.team, + permission_level: Extends::SHARED_INVENTORIES_PERMISSION_LEVELS[:not_shared]) + repository_snapshot.provisioning! + repository_snapshot.reload + end + def default_columns_count Constants::REPOSITORY_SNAPSHOT_TABLE_DEFAULT_STATE['length'] end diff --git a/app/models/step.rb b/app/models/step.rb index bf40e18c7..90eb0cd38 100644 --- a/app/models/step.rb +++ b/app/models/step.rb @@ -13,10 +13,10 @@ class Step < ApplicationRecord validates :completed, inclusion: { in: [true, false] } validates :user, :protocol, presence: true validates :completed_on, presence: true, if: proc { |s| s.completed? } + validates :position, uniqueness: { scope: :protocol }, if: :position_changed? before_validation :set_completed_on, if: :completed_changed? before_save :set_last_modified_by - around_save :adjust_positions_on_save, if: :position_changed? before_destroy :cascade_before_destroy after_destroy :adjust_positions_after_destroy @@ -124,25 +124,66 @@ class Step < ApplicationRecord end end + def move_up + return if position.zero? + + move_in_protocol(:up) + end + + def move_down + return if position == protocol.steps.count - 1 + + move_in_protocol(:down) + end + private - def adjust_positions_on_save - step_to_swap = protocol.steps.find_by(position: position) + def move_in_protocol(direction) + transaction do + re_index_following_steps - return yield unless step_to_swap + case direction + when :up + new_position = position - 1 + when :down + new_position = position + 1 + else + return + end - position_to_swap = position_was - step_to_swap.position = -1 - yield - step_to_swap.update!(position: position_to_swap) + step_to_swap = protocol.steps.find_by(position: new_position) + position_to_swap = position + + if step_to_swap + step_to_swap.update!(position: -1) + update!(position: new_position) + step_to_swap.update!(position: position_to_swap) + else + update!(position: new_position) + end + end end def adjust_positions_after_destroy - protocol.steps.where('position > ?', position).find_each do |step| + re_index_following_steps + protocol.steps.where('position > ?', position).order(:position).each do |step| step.update!(position: step.position - 1) end end + def re_index_following_steps + steps = protocol.steps.where(position: position..).order(:position).where.not(id: id) + i = position + steps.each do |step| + i += 1 + step.position = i + end + + steps.reverse_each do |step| + step.save! if step.position_changed? + end + end + def cascade_before_destroy assets.each(&:destroy) tables.each(&:destroy) diff --git a/app/permissions/experiment.rb b/app/permissions/experiment.rb index f88eb23f9..4fc625673 100644 --- a/app/permissions/experiment.rb +++ b/app/permissions/experiment.rb @@ -25,7 +25,17 @@ Canaid::Permissions.register_for(Experiment) do # module: create, copy, reposition, create/update/delete connection, # assign/reassign/unassign tags can :manage_experiment do |user, experiment| - user.is_user_or_higher_of_project?(experiment.project) + user.is_user_or_higher_of_project?(experiment.project) && + MyModule.joins(:experiment) + .where(experiment: experiment) + .preload(my_module_status: :my_module_status_implications) + .all? do |my_module| + if my_module.my_module_status + my_module.my_module_status.my_module_status_implications.all? { |implication| implication.call(my_module) } + else + true + end + end end # experiment: archive @@ -53,82 +63,6 @@ Canaid::Permissions.register_for(Experiment) do end end -Canaid::Permissions.register_for(MyModule) do - # Module, its experiment and its project must be active for all the specified - # permissions - %i(manage_module - manage_users_in_module - assign_repository_rows_to_module - complete_module - create_comments_in_module - create_my_module_repository_snapshot - manage_my_module_repository_snapshots) - .each do |perm| - can perm do |_, my_module| - my_module.active? && - my_module.experiment.active? && - my_module.experiment.project.active? - end - end - - # module: update - # result: create, update - can :manage_module do |user, my_module| - can_manage_experiment?(user, my_module.experiment) - end - - # module: archive - can :archive_module do |user, my_module| - can_manage_experiment?(user, my_module.experiment) - end - - # NOTE: Must not be dependent on canaid parmision for which we check if it's - # active - # module: restore - can :restore_module do |user, my_module| - user.is_user_or_higher_of_project?(my_module.experiment.project) && - my_module.archived? - end - - # module: move - can :move_module do |user, my_module| - can_manage_experiment?(user, my_module.experiment) - end - - # module: assign/reassign/unassign users - can :manage_users_in_module do |user, my_module| - user.is_owner_of_project?(my_module.experiment.project) - end - - # module: assign/unassign repository record - # NOTE: Use 'module_page? &&' before calling this permission! - can :assign_repository_rows_to_module do |user, my_module| - user.is_technician_or_higher_of_project?(my_module.experiment.project) - end - - # module: complete/uncomplete - can :complete_module do |user, my_module| - user.is_technician_or_higher_of_project?(my_module.experiment.project) - end - - # module: create comment - # result: create comment - # step: create comment - can :create_comments_in_module do |user, my_module| - can_create_comments_in_project?(user, my_module.experiment.project) - end - - # module: create a snapshot of repository item - can :create_my_module_repository_snapshot do |user, my_module| - user.is_technician_or_higher_of_project?(my_module.experiment.project) - end - - # module: make a repository snapshot selected - can :manage_my_module_repository_snapshots do |user, my_module| - user.is_technician_or_higher_of_project?(my_module.experiment.project) - end -end - Canaid::Permissions.register_for(Protocol) do # Protocol needs to be in a module for all Protocol permissions below # experiment level @@ -170,7 +104,7 @@ Canaid::Permissions.register_for(Protocol) do # step: complete/uncomplete can :complete_or_checkbox_step do |user, protocol| - can_complete_module?(user, protocol.my_module) + can_change_my_module_flow_status?(user, protocol.my_module) end end diff --git a/app/permissions/my_module.rb b/app/permissions/my_module.rb new file mode 100644 index 000000000..a83fe4495 --- /dev/null +++ b/app/permissions/my_module.rb @@ -0,0 +1,82 @@ +Canaid::Permissions.register_for(MyModule) do + # Module, its experiment and its project must be active for all the specified + # permissions + %i(manage_module + manage_users_in_module + assign_repository_rows_to_module + assign_sample_to_module + create_comments_in_module + create_my_module_repository_snapshot + manage_my_module_repository_snapshots) + .each do |perm| + can perm do |_, my_module| + my_module.active? && + !my_module.status_changing? && + my_module.experiment.active? && + my_module.experiment.project.active? + end + end + + # module: update + # result: create, update + can :manage_module do |user, my_module| + can_manage_experiment?(user, my_module.experiment) + end + + # module: archive + can :archive_module do |user, my_module| + can_manage_experiment?(user, my_module.experiment) + end + + # NOTE: Must not be dependent on canaid parmision for which we check if it's + # active + # module: restore + can :restore_module do |user, my_module| + user.is_user_or_higher_of_project?(my_module.experiment.project) && + my_module.archived? + end + + # module: move + can :move_module do |user, my_module| + can_manage_experiment?(user, my_module.experiment) + end + + # module: assign/reassign/unassign users + can :manage_users_in_module do |user, my_module| + user.is_owner_of_project?(my_module.experiment.project) + end + + # module: assign/unassign repository record + # NOTE: Use 'module_page? &&' before calling this permission! + can :assign_repository_rows_to_module do |user, my_module| + user.is_technician_or_higher_of_project?(my_module.experiment.project) + end + + # module: assign/unassign sample + # NOTE: Use 'module_page? &&' before calling this permission! + can :assign_sample_to_module do |user, my_module| + user.is_technician_or_higher_of_project?(my_module.experiment.project) + end + + # module: change_flow_status + can :change_my_module_flow_status do |user, my_module| + user.is_technician_or_higher_of_project?(my_module.experiment.project) + end + + # module: create comment + # result: create comment + # step: create comment + can :create_comments_in_module do |user, my_module| + can_create_comments_in_project?(user, my_module.experiment.project) + end + + # module: create a snapshot of repository item + can :create_my_module_repository_snapshot do |user, my_module| + user.is_technician_or_higher_of_project?(my_module.experiment.project) + end + + # module: make a repository snapshot selected + can :manage_my_module_repository_snapshots do |user, my_module| + user.is_technician_or_higher_of_project?(my_module.experiment.project) + end +end diff --git a/app/permissions/project.rb b/app/permissions/project.rb index 3747d438c..b557a69fd 100644 --- a/app/permissions/project.rb +++ b/app/permissions/project.rb @@ -37,7 +37,17 @@ Canaid::Permissions.register_for(Project) do # project: update/delete, assign/reassign/unassign users can :manage_project do |user, project| - user.is_owner_of_project?(project) + user.is_owner_of_project?(project) && + MyModule.joins(experiment: :project) + .where(experiments: { project: project }) + .preload(my_module_status: :my_module_status_implications) + .all? do |my_module| + if my_module.my_module_status + my_module.my_module_status.my_module_status_implications.all? { |implication| implication.call(my_module) } + else + true + end + end end # project: archive diff --git a/app/serializers/api/v1/task_serializer.rb b/app/serializers/api/v1/task_serializer.rb index 6064c2a66..cf99f2535 100644 --- a/app/serializers/api/v1/task_serializer.rb +++ b/app/serializers/api/v1/task_serializer.rb @@ -8,7 +8,8 @@ module Api include InputSanitizeHelper type :tasks - attributes :id, :name, :started_on, :due_date, :description, :state, :archived + attributes :id, :name, :started_on, :due_date, :description, :state, :archived, :status_id, :status_name, + :prev_status_id, :prev_status_name, :next_status_id, :next_status_name has_many :output_tasks, key: :outputs, serializer: TaskSerializer, class_name: 'MyModule' @@ -16,6 +17,30 @@ module Api serializer: TaskSerializer, class_name: 'MyModule' + def status_id + object.my_module_status_id + end + + def status_name + object.my_module_status.name + end + + def prev_status_id + object.my_module_status.previous_status&.id + end + + def prev_status_name + object.my_module_status.previous_status&.name + end + + def next_status_id + object.my_module_status.next_status&.id + end + + def next_status_name + object.my_module_status.next_status&.name + end + def output_tasks object.my_modules end diff --git a/app/serializers/api/v1/workflow_serializer.rb b/app/serializers/api/v1/workflow_serializer.rb new file mode 100644 index 000000000..8488ceb4c --- /dev/null +++ b/app/serializers/api/v1/workflow_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Api + module V1 + class WorkflowSerializer < ActiveModel::Serializer + type :workflows + + attributes :id, :name, :description, :visibility, :team_id + end + end +end diff --git a/app/serializers/api/v1/workflow_status_serializer.rb b/app/serializers/api/v1/workflow_status_serializer.rb new file mode 100644 index 000000000..a20e8fa49 --- /dev/null +++ b/app/serializers/api/v1/workflow_status_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Api + module V1 + class WorkflowStatusSerializer < ActiveModel::Serializer + type :workflow_statuses + + attributes :id, :name, :description, :color, :previous_status_id + end + end +end diff --git a/app/services/model_exporters/experiment_exporter.rb b/app/services/model_exporters/experiment_exporter.rb index 51a19898d..eebd9e838 100644 --- a/app/services/model_exporters/experiment_exporter.rb +++ b/app/services/model_exporters/experiment_exporter.rb @@ -52,6 +52,7 @@ module ModelExporters def my_module(my_module) { my_module: my_module, + my_module_status_name: my_module.my_module_status&.name, outputs: my_module.outputs, my_module_tags: my_module.my_module_tags, task_comments: my_module.task_comments, diff --git a/app/services/reports/docx.rb b/app/services/reports/docx.rb index 4fd93088f..75686b989 100644 --- a/app/services/reports/docx.rb +++ b/app/services/reports/docx.rb @@ -34,53 +34,5 @@ class Reports::Docx end @docx end - - def self.link_prepare(scinote_url, link) - link[0] == '/' ? scinote_url + link : link - end - - def self.render_p_element(docx, element, options = {}) - scinote_url = options[:scinote_url] - link_style = options[:link_style] - docx.p do - element[:children].each do |text_el| - if text_el[:type] == 'text' - style = text_el[:style] || {} - text text_el[:value], style - text ' ' if text_el[:value] != '' - elsif text_el[:type] == 'br' && !options[:skip_br] - br - elsif text_el[:type] == 'a' - if text_el[:link] - link_url = Reports::Docx.link_prepare(scinote_url, text_el[:link]) - link text_el[:value], link_url, link_style - else - text text_el[:value], link_style - end - text ' ' if text_el[:value] != '' - end - end - end - end - - def self.render_img_element(docx, element, options = {}) - style = element[:style] - - if options[:table] - max_width = (style[:max_width] / options[:table][:columns].to_f) - if style[:width] > max_width - style[:height] = (max_width / style[:width].to_f) * style[:height] - style[:width] = max_width - end - end - - docx.img element[:data] do - data element[:blob].download - width style[:width] - height style[:height] - align style[:align] || :left - end - end end - # rubocop:enable Style/ClassAndModuleChildren diff --git a/app/services/reports/docx/draw_experiment.rb b/app/services/reports/docx/draw_experiment.rb index e61595846..b1aa74350 100644 --- a/app/services/reports/docx/draw_experiment.rb +++ b/app/services/reports/docx/draw_experiment.rb @@ -22,7 +22,8 @@ module Reports::Docx::DrawExperiment link_style end html = custom_auto_link(experiment.description, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: scinote_url, + link_style: link_style }).html_to_word_converter(html) @docx.p subject['children'].each do |child| public_send("draw_#{child['type_of']}", child, experiment) diff --git a/app/services/reports/docx/draw_my_module.rb b/app/services/reports/docx/draw_my_module.rb index c7640a5cc..c18b2896b 100644 --- a/app/services/reports/docx/draw_my_module.rb +++ b/app/services/reports/docx/draw_my_module.rb @@ -13,17 +13,6 @@ module Reports::Docx::DrawMyModule @docx.p do text I18n.t('projects.reports.elements.module.user_time', timestamp: I18n.l(my_module.created_at, format: :full)), color: color[:gray] - text ' | ' - if my_module.due_date.present? - text I18n.t('projects.reports.elements.module.due_date', - due_date: I18n.l(my_module.due_date, format: :full)), color: color[:gray] - else - text I18n.t('projects.reports.elements.module.no_due_date'), color: color[:gray] - end - if my_module.completed? - text " #{I18n.t('my_modules.states.completed')}", bold: true, color: color[:green] - text " #{I18n.l(my_module.completed_on, format: :full)}", color: color[:gray] - end if my_module.archived? text ' | ' text I18n.t('search.index.archived'), color: color[:gray] @@ -33,15 +22,37 @@ module Reports::Docx::DrawMyModule scinote_url + Rails.application.routes.url_helpers.protocols_my_module_path(my_module), link_style end - if my_module.description.present? - html = custom_auto_link(my_module.description, team: @report_team) - html_to_word_converter(html) - else - @docx.p I18n.t 'projects.reports.elements.module.no_description' + + @docx.p do + if my_module.started_on.present? + text I18n.t('projects.reports.elements.module.started_on', + started_on: I18n.l(my_module.started_on, format: :full)) + else + text I18n.t('projects.reports.elements.module.no_due_date') + end end @docx.p do - text I18n.t 'projects.reports.elements.module.tags_header' + if my_module.due_date.present? + text I18n.t('projects.reports.elements.module.due_date', + due_date: I18n.l(my_module.due_date, format: :full)) + else + text I18n.t('projects.reports.elements.module.no_due_date') + end + end + + status = my_module.my_module_status + @docx.p do + text I18n.t('projects.reports.elements.module.status') + text ' ' + text "[#{status.name}]", color: status.color.delete('#') + if my_module.completed? + text " #{I18n.t('my_modules.states.completed')} #{I18n.l(my_module.completed_on, format: :full)}" + end + end + + @docx.p do + text I18n.t('projects.reports.elements.module.tags_header') if tags.any? my_module.tags.each do |tag| text ' ' @@ -49,10 +60,18 @@ module Reports::Docx::DrawMyModule end else text ' ' - text I18n.t 'projects.reports.elements.module.no_tags' + text I18n.t('projects.reports.elements.module.no_tags') end end + if my_module.description.present? + html = custom_auto_link(my_module.description, team: @report_team) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: scinote_url, + link_style: link_style }).html_to_word_converter(html) + else + @docx.p I18n.t('projects.reports.elements.module.no_description') + end + @docx.p subject['children'].each do |child| public_send("draw_#{child['type_of']}", child, my_module) diff --git a/app/services/reports/docx/draw_my_module_activity.rb b/app/services/reports/docx/draw_my_module_activity.rb index fcf753a68..8747e5937 100644 --- a/app/services/reports/docx/draw_my_module_activity.rb +++ b/app/services/reports/docx/draw_my_module_activity.rb @@ -20,7 +20,7 @@ module Reports::Docx::DrawMyModuleActivity sanitize_input(generate_activity_content(activity, true)) end @docx.p I18n.l(activity_ts, format: :full), color: color[:gray] - html_to_word_converter(activity_text) + Reports::HtmlToWordConverter.new(@docx).html_to_word_converter(activity_text) @docx.p end end diff --git a/app/services/reports/docx/draw_my_module_protocol.rb b/app/services/reports/docx/draw_my_module_protocol.rb index 97d04fc07..881c79aa1 100644 --- a/app/services/reports/docx/draw_my_module_protocol.rb +++ b/app/services/reports/docx/draw_my_module_protocol.rb @@ -11,7 +11,7 @@ module Reports::Docx::DrawMyModuleProtocol timestamp: I18n.l(protocol.created_at, format: :full) @docx.hr html = custom_auto_link(protocol.description, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx).html_to_word_converter(html) @docx.p @docx.p end diff --git a/app/services/reports/docx/draw_result_asset.rb b/app/services/reports/docx/draw_result_asset.rb index b1fda37fa..fb5ffa88d 100644 --- a/app/services/reports/docx/draw_result_asset.rb +++ b/app/services/reports/docx/draw_result_asset.rb @@ -17,7 +17,7 @@ module Reports::Docx::DrawResultAsset user: result.user.full_name, timestamp: I18n.l(timestamp, format: :full)), color: color[:gray] end - asset_image_preparing(asset) if asset.image? + Reports::DocxRenderer.render_asset_image(@docx, asset) if asset.image? subject['children'].each do |child| public_send("draw_#{child['type_of']}", child, result) diff --git a/app/services/reports/docx/draw_result_comments.rb b/app/services/reports/docx/draw_result_comments.rb index b738808d8..101290b24 100644 --- a/app/services/reports/docx/draw_result_comments.rb +++ b/app/services/reports/docx/draw_result_comments.rb @@ -17,7 +17,8 @@ module Reports::Docx::DrawResultComments date: I18n.l(comment_ts, format: :full_date), time: I18n.l(comment_ts, format: :time)), italic: true html = custom_auto_link(comment.message, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) @docx.p end end diff --git a/app/services/reports/docx/draw_result_text.rb b/app/services/reports/docx/draw_result_text.rb index ee87953e0..33abb9867 100644 --- a/app/services/reports/docx/draw_result_text.rb +++ b/app/services/reports/docx/draw_result_text.rb @@ -17,7 +17,8 @@ module Reports::Docx::DrawResultText timestamp: I18n.l(timestamp, format: :full), user: result.user.full_name), color: color[:gray] end html = custom_auto_link(result_text.text, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) subject['children'].each do |child| public_send("draw_#{child['type_of']}", child, result) diff --git a/app/services/reports/docx/draw_step.rb b/app/services/reports/docx/draw_step.rb index 064d3a85a..87ac65c7a 100644 --- a/app/services/reports/docx/draw_step.rb +++ b/app/services/reports/docx/draw_step.rb @@ -27,7 +27,8 @@ module Reports::Docx::DrawStep end if step.description.present? html = custom_auto_link(step.description, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) else @docx.p I18n.t 'projects.reports.elements.step.no_description' end diff --git a/app/services/reports/docx/draw_step_asset.rb b/app/services/reports/docx/draw_step_asset.rb index e8560ddf0..80a87c8da 100644 --- a/app/services/reports/docx/draw_step_asset.rb +++ b/app/services/reports/docx/draw_step_asset.rb @@ -15,6 +15,6 @@ module Reports::Docx::DrawStepAsset timestamp: I18n.l(timestamp, format: :full)), color: color[:gray] end - asset_image_preparing(asset) if asset.image? + Reports::DocxRenderer.render_asset_image(@docx, asset) if asset.image? end end diff --git a/app/services/reports/docx/draw_step_comments.rb b/app/services/reports/docx/draw_step_comments.rb index 5e04956ec..40bee154d 100644 --- a/app/services/reports/docx/draw_step_comments.rb +++ b/app/services/reports/docx/draw_step_comments.rb @@ -17,7 +17,8 @@ module Reports::Docx::DrawStepComments date: I18n.l(comment_ts, format: :full_date), time: I18n.l(comment_ts, format: :time)), italic: true html = custom_auto_link(comment.message, team: @report_team) - html_to_word_converter(html) + Reports::HtmlToWordConverter.new(@docx, { scinote_url: @scinote_url, + link_style: @link_style }).html_to_word_converter(html) @docx.p end end diff --git a/app/services/reports/docx/private_methods.rb b/app/services/reports/docx/private_methods.rb index 257d71e4b..3a17a5d77 100644 --- a/app/services/reports/docx/private_methods.rb +++ b/app/services/reports/docx/private_methods.rb @@ -3,222 +3,6 @@ module Reports::Docx::PrivateMethods private - # RTE fields support - def html_to_word_converter(text) - html = Nokogiri::HTML(text) - raw_elements = recursive_children(html.css('body').children, []) - - # Combined raw text blocks in paragraphs - elements = combine_docx_elements(raw_elements) - - # Draw elements - elements.each do |elem| - if elem[:type] == 'p' - Reports::Docx.render_p_element(@docx, elem, scinote_url: @scinote_url, link_style: @link_style) - elsif elem[:type] == 'table' - tiny_mce_table(elem[:data]) - elsif elem[:type] == 'newline' - style = elem[:style] || {} - # print heading if its heading - # Mixing heading with other style setting causes problems for Word - if %w(h1 h2 h3 h4 h5).include?(style[:style]) - @docx.public_send(style[:style], elem[:value]) - else - @docx.p elem[:value] do - align style[:align] - color style[:color] - bold style[:bold] - italic style[:italic] - end - end - elsif elem[:type] == 'image' - Reports::Docx.render_img_element(@docx, elem) - end - end - end - - def combine_docx_elements(raw_elements) - elements = [] - temp_p = [] - raw_elements.each do |elem| - if %w(image newline table).include? elem[:type] - unless temp_p.empty? - elements.push(type: 'p', children: temp_p) - temp_p = [] - end - elements.push(elem) - elsif %w(br text a).include? elem[:type] - temp_p.push(elem) - end - end - elements.push(type: 'p', children: temp_p) - elements - end - - # Convert HTML structure to plain text structure - def recursive_children(children, elements, options = {}) - children.each do |elem| - if elem.class == Nokogiri::XML::Text - next if elem.text.strip == ' ' # Invisible symbol - - style = paragraph_styling(elem.parent) - type = (style[:align] && style[:align] != :justify) || style[:style] ? 'newline' : 'text' - - text = smart_annotation_check(elem) - - elements.push( - type: type, - value: text.strip.delete(' '), # Invisible symbol - style: style - ) - next - end - - if elem.name == 'br' - elements.push(type: 'br') - next - end - - if elem.name == 'img' && elem.attributes['data-mce-token'] - - image = TinyMceAsset.find_by(id: Base62.decode(elem.attributes['data-mce-token'].value)) - next unless image - - image_path = image_path(image.image) - dimension = FastImage.size(image_path) - - next unless dimension - - style = image_styling(elem, dimension) - - elements.push( - type: 'image', - data: image_path.split('&')[0], - blob: image.blob, - style: style - ) - next - end - - if elem.name == 'a' - elements.push(link_element(elem)) - next - end - - if elem.name == 'table' - elem = tiny_mce_table(elem, nested_table: true) if options[:nested_tables] - elements.push( - type: 'table', - data: elem - ) - next - end - - elements = recursive_children(elem.children, elements) if elem.children - end - elements - end - - def link_element(elem) - text = elem.text - link = elem.attributes['href'].value if elem.attributes['href'] - if elem.attributes['class']&.value == 'record-info-link' - link = nil - text = "##{text}" - end - text = "##{text}" if elem.parent.attributes['class']&.value == 'atwho-inserted' - text = "@#{text}" if elem.attributes['class']&.value == 'atwho-user-popover' - { - type: 'a', - value: text, - link: link - } - end - - def smart_annotation_check(elem) - return "[#{elem.text}]" if elem.parent.attributes['class']&.value == 'sa-type' - - elem.text - end - - # Prepare style for text - def paragraph_styling(elem) - style = elem.attributes['style'] - result = {} - result[:style] = elem.name if elem.name.include? 'h' - result[:bold] = true if elem.name == 'strong' - result[:italic] = true if elem.name == 'em' - style_keys = %w(text-align color) - - if style - style_keys.each do |key| - style_el = style.value.split(';').select { |i| (i.include? key) }[0] - next unless style_el - - value = style_el.split(':')[1].strip if style_el - if key == 'text-align' - result[:align] = value.to_sym - elsif key == 'color' && calculate_color_hsp(value) < 190 - result[:color] = value.delete('#') - end - end - end - result - end - - # Prepare style for images - def image_styling(elem, dimension) - dimension[0] = elem.attributes['width'].value.to_i if elem.attributes['width'] - dimension[1] = elem.attributes['height'].value.to_i if elem.attributes['height'] - - if elem.attributes['style'] - align = if elem.attributes['style'].value.include? 'margin-right' - :center - elsif elem.attributes['style'].value.include? 'float: right' - :right - else - :left - end - end - - margins = Constants::REPORT_DOCX_MARGIN_LEFT + Constants::REPORT_DOCX_MARGIN_RIGHT - max_width = (Constants::REPORT_DOCX_WIDTH - margins) / 20 - - if dimension[0] > max_width - x = max_width - y = dimension[1] * max_width / dimension[0] - else - x = dimension[0] - y = dimension[1] - end - - { - width: x, - height: y, - align: align, - max_width: max_width - } - end - - def asset_image_preparing(asset) - return unless asset - - image_path = image_path(asset.file) - - dimension = FastImage.size(image_path) - x = dimension[0] - y = dimension[1] - if x > 300 - y = y * 300 / x - x = 300 - end - @docx.img image_path.split('&')[0] do - data asset.blob.download - width x - height y - end - end - def initial_document_load @docx.page_size do width Constants::REPORT_DOCX_WIDTH @@ -269,60 +53,4 @@ module Reports::Docx::PrivateMethods green: '2dbe61' } end - - def tiny_mce_table(table_data, options = {}) - docx_table = [] - scinote_url = @scinote_url - link_style = @link_style - table_data.css('tbody').first.children.each do |row| - docx_row = [] - next unless row.name == 'tr' - - row.children.each do |cell| - next unless cell.name == 'td' - - # Parse cell content - formated_cell = recursive_children(cell.children, [], nested_tables: true) - # Combine text elements to single paragraph - formated_cell = combine_docx_elements(formated_cell) - - docx_cell = Caracal::Core::Models::TableCellModel.new do |c| - formated_cell.each do |cell_content| - if cell_content[:type] == 'p' - Reports::Docx.render_p_element(c, cell_content, - scinote_url: scinote_url, link_style: link_style, skip_br: true) - elsif cell_content[:type] == 'table' - c.table formated_cell_content[:data], border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE - elsif cell_content[:type] == 'image' - Reports::Docx.render_img_element(c, cell_content, table: { columns: row.children.length / 3 }) - end - end - end - docx_row.push(docx_cell) - end - docx_table.push(docx_row) - end - - if options[:nested_table] - docx_table - else - @docx.table docx_table, border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE - end - end - - def image_path(attachment) - attachment.service_url - end - - def calculate_color_hsp(color) - return 255 if color.length != 7 - - color = color.delete('#').scan(/.{1,2}/) - rgb = color.map(&:hex) - Math.sqrt( - 0.299 * (rgb[0]**2) + - 0.587 * (rgb[1]**2) + - 0.114 * (rgb[2]**2) - ) - end end diff --git a/app/services/reports/docx_renderer.rb b/app/services/reports/docx_renderer.rb new file mode 100644 index 000000000..ba73b2511 --- /dev/null +++ b/app/services/reports/docx_renderer.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module Reports + class DocxRenderer + def self.render_p_element(docx, element, options = {}) + docx.p do + element[:children].each do |text_el| + if text_el[:type] == 'text' + style = text_el[:style] || {} + text text_el[:value], style + text ' ' if text_el[:value] != '' + elsif text_el[:type] == 'br' && !options[:skip_br] + br + elsif text_el[:type] == 'a' + Reports::DocxRenderer.render_link_element(self, text_el, options) + end + end + end + end + + def self.render_link_element(node, link_item, options = {}) + scinote_url = options[:scinote_url] + link_style = options[:link_style] + + if link_item[:link] + link_url = Reports::Utils.link_prepare(scinote_url, link_item[:link]) + node.link link_item[:value], link_url, link_style + else + node.text link_item[:value], link_style + end + node.text ' ' if link_item[:value] != '' + end + + def self.render_img_element(docx, element, options = {}) + style = element[:style] + + if options[:table] + max_width = (style[:max_width] / options[:table][:columns].to_f) + if style[:width] > max_width + style[:height] = (max_width / style[:width].to_f) * style[:height] + style[:width] = max_width + end + end + + docx.img element[:data] do + data element[:blob].download + width style[:width] + height style[:height] + align style[:align] || :left + end + end + + def self.render_list_element(docx, element, options = {}) + bookmark_items = Reports::DocxRenderer.recursive_list_items_renderer(docx, element) + + bookmark_items.each_with_index do |(key, item), index| + if item[:type] == 'image' + docx.bookmark_start id: index, name: key + docx.p do + br + text item[:blob]&.filename.to_s + end + Reports::DocxRenderer.render_img_element(docx, item) + docx.bookmark_end id: index + elsif item[:type] == 'table' + docx.bookmark_start id: index, name: key + + # Bookmark won't work with table only, empty p element added + docx.p do + br + text '' + end + Reports::DocxRenderer.render_table_element(docx, item, options) + docx.bookmark_end id: index + end + end + end + + # rubocop:disable Metrics/BlockLength + def self.recursive_list_items_renderer(node, element, bookmark_items: {}) + node.public_send(element[:type]) do + element[:data].each do |values_array| + li do + values_array.each do |item| + case item + when Hash + if %w(ul ol li).include?(item[:type]) + Reports::DocxRenderer.recursive_list_items_renderer(self, item, bookmark_items: bookmark_items) + elsif %w(a).include?(item[:type]) + Reports::DocxRenderer.render_link_element(self, item) + elsif %w(image).include?(item[:type]) + bookmark_items[item[:bookmark_id]] = item + link I18n.t('projects.reports.renderers.lists.appended_image', + name: item[:blob]&.filename), item[:bookmark_id] do + internal true + end + elsif %w(table).include?(item[:type]) + bookmark_items[item[:bookmark_id]] = item + link I18n.t('projects.reports.renderers.lists.appended_table'), item[:bookmark_id] do + internal true + end + end + else + text item + end + end + end + end + end + bookmark_items + end + # rubocop:enable Metrics/BlockLength + + def self.render_table_element(docx, element, options = {}) + docx_table = [] + element[:data].each do |row| + docx_row = [] + row[:data].each do |cell| + docx_cell = Caracal::Core::Models::TableCellModel.new do |c| + cell.each do |content| + if content[:type] == 'p' + Reports::DocxRenderer.render_p_element(c, content, options.merge({ skip_br: true })) + elsif content[:type] == 'table' + Reports::DocxRenderer.render_table_element(c, content, options) + elsif content[:type] == 'image' + Reports::DocxRenderer.render_img_element(c, content, table: { columns: row.children.length / 3 }) + end + end + end + docx_row.push(docx_cell) + end + docx_table.push(docx_row) + end + docx.table docx_table, border_size: Constants::REPORT_DOCX_TABLE_BORDER_SIZE + end + + def self.render_asset_image(docx, asset) + return unless asset + + image_path = Reports::Utils.image_path(asset.file) + + dimension = FastImage.size(image_path) + return unless dimension + + x = dimension[0] + y = dimension[1] + if x > 300 + y = y * 300 / x + x = 300 + end + docx.img image_path.split('&')[0] do + data asset.blob.download + width x + height y + end + end + end +end diff --git a/app/services/reports/html_to_word_converter.rb b/app/services/reports/html_to_word_converter.rb new file mode 100644 index 000000000..b5fa20d35 --- /dev/null +++ b/app/services/reports/html_to_word_converter.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +module Reports + class HtmlToWordConverter + def initialize(document, options = {}) + @docx = document + @scinote_url = options[:scinote_url] + @link_style = options[:link_style] + end + + def html_to_word_converter(text) + html = Nokogiri::HTML(text) + raw_elements = recursive_children(html.css('body').children, []).compact + + # Combined raw text blocks in paragraphs + elements = combine_docx_elements(raw_elements) + + # Draw elements + elements.each do |elem| + if elem[:type] == 'p' + Reports::DocxRenderer.render_p_element(@docx, elem, scinote_url: @scinote_url, link_style: @link_style) + elsif elem[:type] == 'table' + Reports::DocxRenderer.render_table_element(@docx, elem) + elsif elem[:type] == 'newline' + style = elem[:style] || {} + # print heading if its heading + # Mixing heading with other style setting causes problems for Word + if %w(h1 h2 h3 h4 h5).include?(style[:style]) + @docx.public_send(style[:style], elem[:value]) + else + @docx.p elem[:value] do + align style[:align] + color style[:color] + bold style[:bold] + italic style[:italic] + end + end + elsif elem[:type] == 'image' + Reports::DocxRenderer.render_img_element(@docx, elem) + elsif %w(ul ol).include?(elem[:type]) + Reports::DocxRenderer.render_list_element(@docx, elem) + end + end + end + + private + + def combine_docx_elements(raw_elements) + # Word does not support some nested elements, move some elements to root level + elements = [] + temp_p = [] + raw_elements.each do |elem| + if %w(image newline table ol ul).include? elem[:type] + unless temp_p.empty? + elements.push(type: 'p', children: temp_p) + temp_p = [] + end + elements.push(elem) + elsif %w(br text a).include? elem[:type] + temp_p.push(elem) + end + end + elements.push(type: 'p', children: temp_p) + elements + end + + # Convert HTML structure to plain text structure + # rubocop:disable Metrics/BlockLength + def recursive_children(children, elements) + children.each do |elem| + if elem.class == Nokogiri::XML::Text + next if elem.text.strip == ' ' # Invisible symbol + + style = paragraph_styling(elem.parent) + type = (style[:align] && style[:align] != :justify) || style[:style] ? 'newline' : 'text' + + text = smart_annotation_check(elem) + + elements.push( + type: type, + value: text.strip.delete(' '), # Invisible symbol + style: style + ) + next + end + + if elem.name == 'br' + elements.push(type: 'br') + next + end + + if elem.name == 'img' + elements.push(img_element(elem)) + next + end + + if elem.name == 'a' + elements.push(link_element(elem)) + next + end + + if elem.name == 'table' + elements.push(tiny_mce_table_element(elem)) + next + end + + if %w(ul ol).include?(elem.name) + elements.push(list_element(elem)) + next + end + elements = recursive_children(elem.children, elements) if elem.children + end + elements + end + + # rubocop:enable Metrics/BlockLength + + def img_element(elem) + return unless elem.attributes['data-mce-token'] + + image = TinyMceAsset.find_by(id: Base62.decode(elem.attributes['data-mce-token'].value)) + return unless image + + image_path = Reports::Utils.image_path(image.image) + dimension = FastImage.size(image_path) + + return unless dimension + + style = image_styling(elem, dimension) + + { type: 'image', data: image_path.split('&')[0], blob: image.blob, style: style } + end + + def link_element(elem) + text = elem.text + link = elem.attributes['href'].value if elem.attributes['href'] + if elem.attributes['class']&.value == 'record-info-link' + link = nil + text = "##{text}" + end + text = "##{text}" if elem.parent.attributes['class']&.value == 'atwho-inserted' + text = "@#{text}" if elem.attributes['class']&.value == 'atwho-user-popover' + { + type: 'a', + value: text, + link: link + } + end + + def list_element(list_element) + data_array = list_element.children.select { |n| %w(li ul ol a img).include?(n.name) }.map do |li_child| + li_child.children.map do |item| + if item.is_a? Nokogiri::XML::Text + item.text.chomp + elsif %w(ul ol).include?(item.name) + list_element(item) + elsif %w(a).include?(item.name) + link_element(item) + elsif %w(img).include?(item.name) + img_element(item)&.merge(bookmark_id: SecureRandom.hex) + elsif %w(table).include?(item.name) + tiny_mce_table_element(item).merge(bookmark_id: SecureRandom.hex) + end + end.reject(&:blank?) + end + { type: list_element.name, data: data_array } + end + + def smart_annotation_check(elem) + return "[#{elem.text}]" if elem.parent.attributes['class']&.value == 'sa-type' + + elem.text + end + + # Prepare style for text + def paragraph_styling(elem) + style = elem.attributes['style'] + result = {} + result[:style] = elem.name if elem.name.include? 'h' + result[:bold] = true if elem.name == 'strong' + result[:italic] = true if elem.name == 'em' + style_keys = %w(text-align color) + + if style + style_keys.each do |key| + style_el = style.value.split(';').select { |i| (i.include? key) }[0] + next unless style_el + + value = style_el.split(':')[1].strip if style_el + if key == 'text-align' + result[:align] = value.to_sym + elsif key == 'color' && Reports::Utils.calculate_color_hsp(value) < 190 + result[:color] = value.delete('#') + end + end + end + result + end + + # Prepare style for images + def image_styling(elem, dimension) + dimension[0] = elem.attributes['width'].value.to_i if elem.attributes['width'] + dimension[1] = elem.attributes['height'].value.to_i if elem.attributes['height'] + + if elem.attributes['style'] + align = if elem.attributes['style'].value.include? 'margin-right' + :center + elsif elem.attributes['style'].value.include? 'float: right' + :right + else + :left + end + end + + margins = Constants::REPORT_DOCX_MARGIN_LEFT + Constants::REPORT_DOCX_MARGIN_RIGHT + max_width = (Constants::REPORT_DOCX_WIDTH - margins) / 20 + + if dimension[0] > max_width + x = max_width + y = dimension[1] * max_width / dimension[0] + else + x = dimension[0] + y = dimension[1] + end + + { + width: x, + height: y, + align: align, + max_width: max_width + } + end + + def tiny_mce_table_element(table_element) + # array of elements + rows = table_element.css('tbody').first.children.map do |row| + next unless row.name == 'tr' + + cells = row.children.map do |cell| + next unless cell.name == 'td' + + # Parse cell content + formated_cell = recursive_children(cell.children, []) + + # Combine text elements to single paragraph + formated_cell = combine_docx_elements(formated_cell) + formated_cell + end.reject(&:blank?) + { type: 'tr', data: cells } + end.reject(&:blank?) + { type: 'table', data: rows } + end + end +end diff --git a/app/services/reports/utils.rb b/app/services/reports/utils.rb new file mode 100644 index 000000000..b61f1cf1c --- /dev/null +++ b/app/services/reports/utils.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Reports + class Utils + def self.link_prepare(scinote_url, link) + link[0] == '/' ? scinote_url + link : link + end + + def self.image_path(attachment) + attachment.service_url + end + + def self.calculate_color_hsp(color) + return 255 if color.length != 7 + + color = color.delete('#').scan(/.{1,2}/) + rgb = color.map(&:hex) + Math.sqrt( + 0.299 * (rgb[0]**2) + + 0.587 * (rgb[1]**2) + + 0.114 * (rgb[2]**2) + ) + end + end +end diff --git a/app/services/smart_annotations/html_preview.rb b/app/services/smart_annotations/html_preview.rb index 7a547379b..926a2c22f 100644 --- a/app/services/smart_annotations/html_preview.rb +++ b/app/services/smart_annotations/html_preview.rb @@ -13,47 +13,36 @@ module SmartAnnotations def generate_prj_snippet(_, object) if object.archived? - return "Prj #{object.name}" \ - "#{I18n.t('atwho.res.archived')}" + return "Prj#{object.name} #{I18n.t('atwho.res.archived')}" end - "Prj " \ - "#{object.name}" + "Prj#{object.name}" end def generate_exp_snippet(_, object) if object.archived? - return "Exp " \ - "#{object.name} #{I18n.t('atwho.res.archived')}" + return "Exp#{object.name} #{I18n.t('atwho.res.archived')}" end - "Exp " \ - "#{object.name}" + "Exp#{object.name}" end def generate_tsk_snippet(_, object) if object.archived? - return "Tsk #{object.name} #{I18n.t('atwho.res.archived')}" + return "Tsk#{object.name} #{I18n.t('atwho.res.archived')}" end - "Tsk " \ "" \ - "#{object.name}" + "Tsk#{object.name}" end def generate_rep_item_snippet(name, object) - if object + if object&.repository repository_name = fetch_repository_name(object) - link = if object.archived? + if object.archived? + return "#{trim_repository_name(repository_name)} " \ "#{object.name} #{I18n.t('atwho.res.archived')}" - else - "#{object.name}" - end - - return "#{trim_repository_name(repository_name)} " + link + else + return "#{trim_repository_name(repository_name)}#{object.name}" + end end "Inv " \ "#{name} #{I18n.t('atwho.res.deleted')}" diff --git a/app/services/smart_annotations/permission_eval.rb b/app/services/smart_annotations/permission_eval.rb index e026f93aa..15eb9af60 100644 --- a/app/services/smart_annotations/permission_eval.rb +++ b/app/services/smart_annotations/permission_eval.rb @@ -12,25 +12,28 @@ module SmartAnnotations private def validate_prj_permissions(user, team, object) - object.team.id == team.id && can_read_project?(user, object) + object.archived = false + permission_check = object.team.id == team.id && can_read_project?(user, object) + object.archived = true if object.archived_changed? + permission_check end def validate_exp_permissions(user, team, object) - object.project.team.id == team.id && can_read_experiment?(user, object) + object.archived = false + permission_check = object.project.team.id == team.id && can_read_experiment?(user, object) + object.archived = true if object.archived_changed? + permission_check end def validate_tsk_permissions(user, team, object) - object.experiment.project.team.id == team.id && - can_read_experiment?(user, object.experiment) + validate_exp_permissions(user, team, object.experiment) end def validate_rep_item_permissions(user, team, object) - if object.repository - return can_read_repository?(user, object.repository) - end + return can_read_repository?(user, object.repository) if object.repository # handles discarded repositories - repository = Repository.with_discarded.find_by_id(object.repository_id) + repository = Repository.with_discarded.find_by(id: object.repository_id) # evaluate to false if repository not found return false unless repository diff --git a/app/services/smart_annotations/tag_to_html.rb b/app/services/smart_annotations/tag_to_html.rb index 314fbd6b6..a60b1a8d6 100644 --- a/app/services/smart_annotations/tag_to_html.rb +++ b/app/services/smart_annotations/tag_to_html.rb @@ -39,7 +39,7 @@ module SmartAnnotations end def repository_item(name, user, team, type, object) - if object + if object&.repository return unless SmartAnnotations::PermissionEval.check(user, team, type, object) return SmartAnnotations::HtmlPreview.html(nil, type, object) diff --git a/app/services/team_importer.rb b/app/services/team_importer.rb index 501c8a3cb..1c0ef435e 100644 --- a/app/services/team_importer.rb +++ b/app/services/team_importer.rb @@ -624,7 +624,7 @@ class TeamImporter def create_my_modules(my_modules_json, experiment, user_id = nil) puts('Creating my_modules...') my_modules_json.each do |my_module_json| - my_module = MyModule.new(my_module_json['my_module']) + my_module = MyModule.new(my_module_json['my_module'].except('my_module_status_name')) orig_my_module_id = my_module.id my_module.id = nil my_module.my_module_group_id = @@ -636,6 +636,16 @@ class TeamImporter my_module.archived_by_id = find_user(my_module.archived_by_id) my_module.restored_by_id = find_user(my_module.restored_by_id) my_module.experiment = experiment + + # Find matching status from default flow + default_flow = MyModuleStatusFlow.global.first + + if default_flow.present? + status = default_flow.my_module_statuses.find_by(name: my_module_json['my_module']['my_module_status_name']) + status ||= default_flow.initial_status + my_module.my_module_status = status + end + my_module.save! @my_module_mappings[orig_my_module_id] = my_module.id @my_module_counter += 1 diff --git a/app/services/user_data_deletion.rb b/app/services/user_data_deletion.rb index 02f9198f9..8dfda4718 100644 --- a/app/services/user_data_deletion.rb +++ b/app/services/user_data_deletion.rb @@ -1,7 +1,6 @@ class UserDataDeletion def self.delete_team_data(team) ActiveRecord::Base.logger = Logger.new(STDOUT) - Step.skip_callback(:destroy, :after, :cascade_after_destroy) team.transaction do # Destroy tiny_mce_assets if team.tiny_mce_assets.present? @@ -80,7 +79,6 @@ class UserDataDeletion team.destroy! # raise ActiveRecord::Rollback end - Step.set_callback(:destroy, :after, :cascade_after_destroy) end def self.destroy_protocol(protocol) diff --git a/app/utilities/smart_annotation.rb b/app/utilities/smart_annotation.rb index dc19a4427..f12d8087d 100644 --- a/app/utilities/smart_annotation.rb +++ b/app/utilities/smart_annotation.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + class SmartAnnotation include ActionView::Helpers::SanitizeHelper include ActionView::Helpers::TextHelper @@ -12,67 +14,25 @@ class SmartAnnotation def my_modules # Search tasks - res = MyModule - .search(@current_user, false, @query, 1, @current_team) - .limit(Constants::ATWHO_SEARCH_LIMIT) - - modules_list = [] - res.each do |my_module_res| - my_mod = {} - my_mod['id'] = my_module_res.id.base62_encode - my_mod['name'] = sanitize(my_module_res.name) - my_mod['archived'] = my_module_res.archived - my_mod['experimentName'] = truncate( - sanitize(my_module_res.experiment.name, - length: Constants::NAME_TRUNCATION_LENGTH) - ) - my_mod['projectName'] = truncate( - sanitize(my_module_res.experiment.project.name, - length: Constants::NAME_TRUNCATION_LENGTH) - ) - my_mod['type'] = 'tsk' - - modules_list << my_mod - end - modules_list + MyModule.search_by_name(@current_user, @current_team, @query, intersect: true).active + .joins(experiment: :project) + .where(projects: { archived: false }, experiments: { archived: false }) + .limit(Constants::ATWHO_SEARCH_LIMIT + 1) end def projects # Search projects - res = Project - .search(@current_user, false, @query, 1, @current_team) - .limit(Constants::ATWHO_SEARCH_LIMIT) - - projects_list = [] - res.each do |project_res| - prj = {} - prj['id'] = project_res.id.base62_encode - prj['name'] = sanitize(project_res.name) - prj['type'] = 'prj' - projects_list << prj - end - projects_list + Project.search_by_name(@current_user, @current_team, @query, intersect: true) + .where(archived: false) + .limit(Constants::ATWHO_SEARCH_LIMIT + 1) end def experiments # Search experiments - res = Experiment - .search(@current_user, false, @query, 1, @current_team) - .limit(Constants::ATWHO_SEARCH_LIMIT) - - experiments_list = [] - res.each do |experiment_res| - exp = {} - exp['id'] = experiment_res.id.base62_encode - exp['name'] = sanitize(experiment_res.name) - exp['type'] = 'exp' - exp['projectName'] = truncate( - sanitize(experiment_res.project.name, - length: Constants::NAME_TRUNCATION_LENGTH) - ) - experiments_list << exp - end - experiments_list + Experiment.search_by_name(@current_user, @current_team, @query, intersect: true) + .joins(:project) + .where(projects: { archived: false }, experiments: { archived: false }) + .limit(Constants::ATWHO_SEARCH_LIMIT + 1) end @@ -80,8 +40,8 @@ class SmartAnnotation res = RepositoryRow .active .where(repository: repository) - .where_attributes_like('name', @query, at_search: true) - .limit(Constants::ATWHO_SEARCH_LIMIT) + .search_by_name(@current_user, @current_team, @query, intersect: true) + .limit(Constants::ATWHO_SEARCH_LIMIT + 1) rep_items_list = [] splitted_name = repository.name.gsub(/[^0-9a-z ]/i, '').split repository_tag = @@ -100,10 +60,9 @@ class SmartAnnotation repository_tag.downcase! res.each do |rep_row| rep_item = {} - rep_item['id'] = rep_row.id.base62_encode - rep_item['name'] = sanitize(rep_row.name) - rep_item['repository_tag'] = repository_tag - rep_item['type'] = 'rep_item' + rep_item[:id] = rep_row.id.base62_encode + rep_item[:name] = sanitize(rep_row.name) + rep_item[:repository_tag] = repository_tag rep_items_list << rep_item end rep_items_list diff --git a/app/views/canvas/edit/_my_module.html.erb b/app/views/canvas/edit/_my_module.html.erb index 228a8a405..62e9b3132 100644 --- a/app/views/canvas/edit/_my_module.html.erb +++ b/app/views/canvas/edit/_my_module.html.erb @@ -36,7 +36,7 @@ <%= t('experiments.canvas.edit.move_module') %> <% end %> - <% if module_group.my_modules.all? { |my_module| can_move_module?(my_module) } %> + <% if module_group&.my_modules&.all? { |my_module| can_move_module?(my_module) } %>
  • <%= t('experiments.canvas.edit.move_module_group') %>
  • @@ -46,7 +46,7 @@ <%= t('experiments.canvas.edit.delete_module') %> <% end %> - <% if module_group.my_modules.all? { |my_module| can_archive_module?(my_module) } %> + <% if module_group&.my_modules&.all? { |my_module| can_archive_module?(my_module) } %>
  • <%= t('experiments.canvas.edit.delete_module_group') %>
  • diff --git a/app/views/canvas/full_zoom/_my_module.html.erb b/app/views/canvas/full_zoom/_my_module.html.erb index 314f8e38f..0707f6328 100644 --- a/app/views/canvas/full_zoom/_my_module.html.erb +++ b/app/views/canvas/full_zoom/_my_module.html.erb @@ -29,14 +29,23 @@
    - <% if !my_module.completed? && can_manage_module?(my_module) %> - <%= link_to due_date_my_module_path(my_module, format: :json), remote: true, - class: "due-date-link due-date-refresh" do %> +
    + <% if !my_module.completed? && can_manage_module?(my_module) %> + <%= link_to due_date_my_module_path(my_module, format: :json), remote: true, + class: "due-date-link due-date-refresh" do %> + <%= render partial: "my_modules/card_due_date_label.html.erb", locals: { my_module: my_module, format: :full_date } %> + <% end %> + <% else %> <%= render partial: "my_modules/card_due_date_label.html.erb", locals: { my_module: my_module, format: :full_date } %> <% end %> - <% else %> - <%= render partial: "my_modules/card_due_date_label.html.erb", locals: { my_module: my_module, format: :full_date } %> - <% end %> +
    +
    + <% if my_module.status_changing %> + + <%= t('experiments.canvas.full_zoom.status_transitioning_label') %> + <% end %> + <%= my_module.my_module_status.name %> +