diff --git a/app/assets/javascripts/dashboard/current_tasks.js b/app/assets/javascripts/dashboard/current_tasks.js index 27046913a..62f8e8ff7 100644 --- a/app/assets/javascripts/dashboard/current_tasks.js +++ b/app/assets/javascripts/dashboard/current_tasks.js @@ -3,7 +3,7 @@ var DasboardCurrentTasksWidget = (function() { var sortFilter = '.curent-tasks-filters .sort-filter'; - var viewFilter = '.curent-tasks-filters .view-filter'; + var statusFilter = '.curent-tasks-filters .view-filter'; var projectFilter = '.curent-tasks-filters .project-filter'; var experimentFilter = '.curent-tasks-filters .experiment-filter'; @@ -36,7 +36,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; @@ -48,7 +48,7 @@ var DasboardCurrentTasksWidget = (function() { return dropdownSelector.getValues(experimentFilter) || dropdownSelector.getValues(projectFilter) || $('.current-tasks-widget .task-search-field').val().length > 0 - || dropdownSelector.getValues(viewFilter) !== 'uncompleted'; + || dropdownSelector.getValues(statusFilter) !== 'uncompleted'; } function loadCurrentTasksList(newList) { @@ -57,7 +57,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') }; @@ -85,7 +85,6 @@ var DasboardCurrentTasksWidget = (function() { e.stopPropagation(); e.preventDefault(); dropdownSelector.selectValue(sortFilter, 'due_date'); - dropdownSelector.selectValue(viewFilter, 'uncompleted'); dropdownSelector.clearData(projectFilter); dropdownSelector.clearData(experimentFilter); }); @@ -98,12 +97,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, { @@ -143,7 +139,7 @@ var DasboardCurrentTasksWidget = (function() { e.stopPropagation(); e.preventDefault(); dropdownSelector.closeDropdown(sortFilter); - dropdownSelector.closeDropdown(viewFilter); + dropdownSelector.closeDropdown(statusFilter); dropdownSelector.closeDropdown(projectFilter); dropdownSelector.closeDropdown(experimentFilter); }); diff --git a/app/assets/javascripts/my_modules.js b/app/assets/javascripts/my_modules.js index bb2505b1a..562625293 100644 --- a/app/assets/javascripts/my_modules.js +++ b/app/assets/javascripts/my_modules.js @@ -1,4 +1,4 @@ -/* global I18n dropdownSelector */ +/* global I18n dropdownSelector HelperModule animateSpinner */ /* eslint-disable no-use-before-define */ function initTaskCollapseState() { @@ -226,34 +226,33 @@ function bindEditTagsAjax() { }); } -// 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() { +function applyTaskStatusChangeCallBack() { + $('.task-flows').on('click', '#dropdownTaskFlowList > li[data-state-id]', function() { + var list = $('#dropdownTaskFlowList'); + var item = $(this); + var container = list.closest('.task-flows'); + animateSpinner(); + $.ajax({ + url: list.data('link-url'), + type: 'PATCH', + dataType: 'json', + data: { my_module: { status_id: item.data('state-id') } }, + success: function(data) { + container.html(data.content); + animateSpinner(null, false); + }, + 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.errors, 'danger'); + } else { + HelperModule.flashAlertMsg('error', 'danger'); } - }); + } }); + }); } function initTagsSelector() { @@ -380,7 +379,7 @@ function initAssignedUsersSelector() { } initTaskCollapseState(); -applyTaskCompletedCallBack(); +applyTaskStatusChangeCallBack(); initTagsSelector(); bindEditTagsAjax(); initStartDatePicker(); 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/sitewide/dropdown_selector.js b/app/assets/javascripts/sitewide/dropdown_selector.js index da29489a9..4c045dbe4 100644 --- a/app/assets/javascripts/sitewide/dropdown_selector.js +++ b/app/assets/javascripts/sitewide/dropdown_selector.js @@ -179,9 +179,20 @@ 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 = []; + $.each($(selector).find('option:selected'), 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 function prepareCustomDropdownIcon(config) { @@ -422,8 +433,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 diff --git a/app/assets/stylesheets/my_modules/protocols/index.scss b/app/assets/stylesheets/my_modules/protocols/index.scss index 8a0782a75..bde9da234 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 max-content; + + .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/status_flow.scss b/app/assets/stylesheets/my_modules/status_flow.scss new file mode 100644 index 000000000..81e8a93f0 --- /dev/null +++ b/app/assets/stylesheets/my_modules/status_flow.scss @@ -0,0 +1,92 @@ +// 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: 15em; + + .caret { + margin: 8px 0; + } + } + + .dropdown-menu > li { + line-height: 35px; + } + } +} + + +#statusFlowModal { + .status-flow { + padding: 2em; + + .status-container { + align-items: center; + display: grid; + grid-template-columns: 1fr min-content 1fr; + justify-content: space-around; + position: relative; + + .current-status { + @include font-small; + justify-self: end; + + .fas { + margin: 0 .5em; + } + } + + .status-block { + 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..c9125f939 100644 --- a/app/assets/stylesheets/projects.scss +++ b/app/assets/stylesheets/projects.scss @@ -316,10 +316,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 +335,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 c3d07e9c1..e802b82c9 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_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/themes/scinote.scss b/app/assets/stylesheets/themes/scinote.scss index 42f54a018..fff2c85c3 100644 --- a/app/assets/stylesheets/themes/scinote.scss +++ b/app/assets/stylesheets/themes/scinote.scss @@ -742,6 +742,44 @@ 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; + margin: 3px 0; + padding: 2px 8px; + white-space: nowrap; + width: fit-content; + } + } + + .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; diff --git a/app/controllers/dashboard/current_tasks_controller.rb b/app/controllers/dashboard/current_tasks_controller.rb index 30cd0b22a..b8741d667 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 = filter_by_state(tasks) case task_filters[:sort] when 'start_date' 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..eb738f8db --- /dev/null +++ b/app/controllers/my_module_status_flow_controller.rb @@ -0,0 +1,23 @@ +# 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.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_modules_controller.rb b/app/controllers/my_modules_controller.rb index 6f2e8c931..e0d1e9279 100644 --- a/app/controllers/my_modules_controller.rb +++ b/app/controllers/my_modules_controller.rb @@ -13,7 +13,7 @@ class MyModulesController < ApplicationController 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_update_state_permissions, only: :update_state before_action :set_inline_name_editing, only: %i(protocols results activities archive) layout 'fluid'.freeze @@ -259,99 +259,24 @@ 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 - # Complete/uncomplete task - def toggle_task_state - respond_to do |format| - @my_module.completed? ? @my_module.uncompleted! : @my_module.completed! - task_completion_activity + def update_state + new_status = @my_module.my_module_status_flow.my_module_statuses.find_by(id: update_status_params[:status_id]) + return render_404 unless new_status - # 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 + @my_module.update(my_module_status: new_status) - 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 + render json: { content: render_to_string( + partial: 'my_modules/status_flow/task_flow_button.html.erb', + locals: { my_module: @my_module }) + }, status: :ok - 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 - 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 +309,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 +340,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/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/models/my_module.rb b/app/models/my_module.rb index 2ed62b6d2..537999eb8 100644 --- a/app/models/my_module.rb +++ b/app/models/my_module.rb @@ -8,6 +8,8 @@ class MyModule < ApplicationRecord before_create :create_blank_protocol before_validation :set_completed_on, if: :state_changed? + before_create :assign_default_status_flow + before_save :exec_status_consequences, if: :my_module_status_id_changed? auto_strip_attributes :name, :description, nullify: false validates :name, @@ -20,6 +22,9 @@ class MyModule < ApplicationRecord validate :coordinates_uniqueness_check, if: :active? validates :completed_on, presence: true, if: proc { |mm| mm.completed? } + validate :check_status_conditions, if: :my_module_status_id_changed? + validate :check_status_implications, unless: :my_module_status_id_changed? + belongs_to :created_by, foreign_key: 'created_by_id', class_name: 'User', @@ -38,6 +43,8 @@ class MyModule < ApplicationRecord 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 + 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 @@ -375,40 +382,6 @@ class MyModule < ApplicationRecord final end - - # Generate the samples belonging to this module - # in JSON form, suitable for display in handsontable.js - def samples_json_hot(order) - data = [] - samples.order(created_at: order).each do |sample| - sample_json = [] - sample_json << sample.name - if sample.sample_type.present? - sample_json << sample.sample_type.name - else - sample_json << I18n.t("samples.table.no_type") - end - if sample.sample_group.present? - sample_json << sample.sample_group.name - else - sample_json << I18n.t("samples.table.no_group") - end - sample_json << I18n.l(sample.created_at, format: :full) - sample_json << sample.user.full_name - data << sample_json - end - - # Prepare column headers - headers = [ - I18n.t("samples.table.sample_name"), - I18n.t("samples.table.sample_type"), - I18n.t("samples.table.sample_group"), - I18n.t("samples.table.added_on"), - I18n.t("samples.table.added_by") - ] - { data: data, headers: headers } - end - # Generate the repository rows belonging to this module # in JSON form, suitable for display in handsontable.js def repository_json_hot(repository, order) @@ -552,4 +525,34 @@ 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 unless MyModuleStatusFlow.global.any? + + 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 exec_status_consequences + return if my_module_status.blank? + + my_module_status.my_module_status_consequences.each do |consequence| + consequence.call(self) + 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..101606c40 --- /dev/null +++ b/app/models/my_module_status.rb @@ -0,0 +1,59 @@ +# 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 + + 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..7bc18740e --- /dev/null +++ b/app/models/my_module_status_condition.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MyModuleStatusCondition < ApplicationRecord + belongs_to :my_module_status +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..887eaa739 --- /dev/null +++ b/app/models/my_module_status_conditions/active.rb @@ -0,0 +1,10 @@ +# 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, 'MyModule should be active') unless my_module.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..4aa31924d --- /dev/null +++ b/app/models/my_module_status_consequence.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MyModuleStatusConsequence < ApplicationRecord + belongs_to :my_module_status +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_flow.rb b/app/models/my_module_status_flow.rb new file mode 100644 index 000000000..2dbb3e019 --- /dev/null +++ b/app/models/my_module_status_flow.rb @@ -0,0 +1,24 @@ +# 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 +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..a455500d9 --- /dev/null +++ b/app/models/my_module_status_implication.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MyModuleStatusImplication < ApplicationRecord + belongs_to :my_module_status +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/permissions/experiment.rb b/app/permissions/experiment.rb index ec4909f27..8cf52fe4b 100644 --- a/app/permissions/experiment.rb +++ b/app/permissions/experiment.rb @@ -25,7 +25,14 @@ 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).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,79 +60,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 - assign_sample_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, archive, move - # result: create, update - can :manage_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: 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: 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 @@ -167,7 +101,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..c3fa763fd --- /dev/null +++ b/app/permissions/my_module.rb @@ -0,0 +1,72 @@ +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 + change_my_module_flow_status + 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, archive, move + # result: create, update + can :manage_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: 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..5b026e637 100644 --- a/app/permissions/project.rb +++ b/app/permissions/project.rb @@ -37,7 +37,14 @@ 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 }).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/services/reports/docx/draw_my_module.rb b/app/services/reports/docx/draw_my_module.rb index c7640a5cc..04edfeccf 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,34 @@ 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('#') + 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 +57,17 @@ 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) + 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/views/canvas/full_zoom/_my_module.html.erb b/app/views/canvas/full_zoom/_my_module.html.erb index 314f8e38f..3f4ef6fa7 100644 --- a/app/views/canvas/full_zoom/_my_module.html.erb +++ b/app/views/canvas/full_zoom/_my_module.html.erb @@ -37,6 +37,9 @@ <% else %> <%= render partial: "my_modules/card_due_date_label.html.erb", locals: { my_module: my_module, format: :full_date } %> <% end %> +
+ <% if my_module.started_on.present? %> + <%= t('projects.reports.elements.module.started_on', started_on: l(my_module.started_on, format: :full)) %> + <% else %> + <%= t("projects.reports.elements.module.no_start_date") %> + <% end %> +
++ <% if my_module.due_date.present? %> + <%= t("projects.reports.elements.module.due_date", due_date: l(my_module.due_date, format: :full)) %> + <% else %> + <%= t("projects.reports.elements.module.no_due_date") %> + <% end %> +
++ <% status = my_module.my_module_status %> + <%= t("projects.reports.elements.module.status") %> + <%= status.name %> +
+