Add step/results linking [SCI-11883][SCI-11884]

This commit is contained in:
Anton 2025-05-14 15:46:03 +02:00
parent 60ae88f64e
commit dbf76acc51
14 changed files with 497 additions and 40 deletions

View file

@ -40,6 +40,12 @@ class ResultsController < ApplicationController
end
end
def list
@results = @my_module.results.active
update_and_apply_user_sort_preference!
end
def create
result = @my_module.results.create!(user: current_user)
log_activity(:add_result, { result: result })

View file

@ -1,53 +1,70 @@
# frozen_string_literal: true
class StepResultsController < ApplicationController
before_action :load_step, only: :create
before_action :load_result, only: :create
before_action :load_step_result, only: :destroy
before_action :check_manage_permissions, only: %i(create destroy)
before_action :load_steps
before_action :load_results
before_action :load_step_results
before_action :check_manage_permissions
def create
def link_results
ActiveRecord::Base.transaction do
@step_result = StepResult.create!(step: @step, result: @result, created_by: current_user)
render json: { step_result: { id: @step_result.id } }
@step_results.where.not(result: @results).destroy_all
@results.where.not(id: @step_results.select(:result_id)).each do |result|
StepResult.create!(step: @steps.first, result: result, created_by: current_user)
end
render json: { results: @steps.first.results.map { |r| { id: r.id, name: r.name } } }, status: :created
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error e.message
render json: { errors: @step_result.errors.full_messages }, status: :unprocessable_entity
render json: { message: :error }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
def destroy
def link_steps
ActiveRecord::Base.transaction do
if @step_result.destroy
render json: {}, status: :ok
else
render json: { errors: @step_result.errors.full_messages }, status: :unprocessable_entity
@step_results.where.not(step: @steps).destroy_all
@steps.where.not(id: @step_results.select(:step_id)).each do |step|
StepResult.create!(step: step, result: @results.first, created_by: current_user)
end
render json: { steps: @results.first.steps.map { |s| { id: s.id, name: s.name } } }, status: :created
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error e.message
render json: { message: :error }, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
@step_result.destroy!
end
private
def load_step
@step = Step.find_by(id: params[:step_id])
render_404 unless @step
def load_steps
@steps = Step.where(id: params[:step_ids])
render_404 and return if (action_name == 'link_results') && @steps.size != 1
render_403 and return if @steps.pluck(:protocol_id).uniq.size > 1
end
def load_result
@result = Result.find_by(id: params[:result_id])
render_404 unless @result
def load_results
@results = Result.where(id: params[:result_ids])
render_404 and return if (action_name == 'link_steps') && @results.size != 1
render_403 and return if @results.pluck(:my_module_id).uniq.size > 1
end
def load_step_result
@step_result = StepResult.find_by(id: params[:id])
render_404 unless @step_result
@step = @step_result.step
@result = @step_result.result
def load_step_results
@step_results = StepResult.where(step: @steps) if action_name == 'link_results'
@step_results = StepResult.where(result: @results) if action_name == 'link_steps'
end
def check_manage_permissions
render_403 unless @step.my_module == @result.my_module && can_manage_my_module?(@step.my_module)
case action_name
when 'link_results'
render_403 and return unless can_manage_my_module?(@steps.first.my_module)
when 'link_steps'
render_403 and return unless can_manage_my_module?(@results.first.my_module)
end
render_403 and return unless (@results + @steps).map(&:my_module).uniq.one?
end
end

View file

@ -7,11 +7,11 @@ class StepsController < ApplicationController
before_action :load_vars, only: %i(update destroy show toggle_step_state update_view_state
update_asset_view_mode elements
attachments upload_attachment duplicate)
before_action :load_vars_nested, only: %i(create index reorder list_protocol_steps add_protocol_steps)
before_action :load_vars_nested, only: %i(create index list reorder list_protocol_steps add_protocol_steps)
before_action :convert_table_contents_to_utf8, only: %i(create update)
before_action :check_protocol_manage_permissions, only: %i(reorder add_protocol_steps)
before_action :check_view_permissions, only: %i(show index attachments elements list_protocol_steps)
before_action :check_view_permissions, only: %i(show index list attachments elements list_protocol_steps)
before_action :check_create_permissions, only: %i(create)
before_action :check_manage_permissions, only: %i(update destroy
update_view_state update_asset_view_mode upload_attachment)
@ -21,6 +21,10 @@ class StepsController < ApplicationController
render json: @protocol.steps.in_order, each_serializer: StepSerializer, user: current_user
end
def list
@steps = @protocol.steps.in_order
end
def elements
render json: @step.step_orderable_elements.order(:position),
each_serializer: StepOrderableElementSerializer,

View file

@ -0,0 +1,132 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block">
{{ i18n.t('protocols.steps.modals.link_results.title') }}
</h4>
</div>
<div v-if="results.length > 0" class="modal-body">
<p>
{{ i18n.t('protocols.steps.modals.link_results.description') }}
</p>
<div class="mt-6">
<label class="sci-label">{{ i18n.t('protocols.steps.modals.link_results.result_label') }}</label>
<SelectDropdown
:options="results"
:value="selectedResults"
@change="changeResults"
:multiple="true"
:withCheckboxes="true"
:placeholder="i18n.t('protocols.steps.modals.link_results.placeholder')" />
</div>
</div>
<div v-else class="modal-body">
<p>
{{ i18n.t('protocols.steps.modals.link_results.empty_description') }}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
{{ i18n.t('general.cancel') }}
</button>
<template v-if="results.length > 0">
<button v-if="step.attributes.results.length == 0" type="submit" class="btn btn-primary" @click="linkResults">
{{ i18n.t('protocols.steps.modals.link_results.link_results') }}
</button>
<button v-else type="submit" class="btn btn-primary" @click="linkResults">
{{ i18n.t('general.save') }}
</button>
</template>
<template v-else>
<a :href="resultsPageUrl" class="btn btn-primary">
{{ i18n.t('protocols.steps.modals.link_results.go_to_results') }}
</a>
</template>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
/* global HelperModule I18n */
import SelectDropdown from '../../shared/select_dropdown.vue';
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin.js';
import {
list_my_module_results_path,
my_module_results_path,
link_results_step_results_path
} from '../../../routes.js';
export default {
name: 'LinksResultsModal',
props: {
step: {
type: Object,
required: true
}
},
mixins: [modalMixin],
components: {
SelectDropdown
},
created() {
this.selectedResults = this.step.attributes.results.map((result) => result.id);
this.loadResults();
},
data() {
return {
results: [],
selectedResults: []
};
},
computed: {
resultsListUrl() {
return list_my_module_results_path({ my_module_id: this.step.attributes.my_module_id });
},
resultsPageUrl() {
return my_module_results_path({ my_module_id: this.step.attributes.my_module_id });
},
resultsLinkUrl() {
return link_results_step_results_path({ format: 'json' });
}
},
methods: {
changeResults(value) {
this.selectedResults = value;
},
linkResults() {
axios.post(
this.resultsLinkUrl,
{
step_ids: this.step.id,
result_ids: this.selectedResults
}
)
.then((response) => {
this.$emit('close');
this.$emit('updateStep', response.data.results);
HelperModule.flashAlertMsg(I18n.t('protocols.steps.modals.link_results.success'), 'success');
}).catch(() => {
HelperModule.flashAlertMsg(I18n.t('protocols.steps.modals.link_results.error'), 'danger');
this.$emit('close');
});
},
loadResults() {
axios.get(this.resultsListUrl)
.then((response) => {
this.results = response.data;
});
}
}
};
</script>

View file

@ -83,8 +83,35 @@
:data-object-type="step.attributes.type"
tabindex="0"
></span> <!-- Hidden element to support legacy code -->
<a href="#"
<button v-if="step.attributes.results.length == 0" class="btn btn-light icon-btn" @click="this.openLinkResultsModal = true">
<i class="sn-icon sn-icon-results"></i>
</button>
<GeneralDropdown v-else ref="linkedResultsDropdown" position="right">
<template v-slot:field>
<button class="btn btn-light icon-btn">
<i class="sn-icon sn-icon-results"></i>
<span class="absolute top-1 right-1 h-4 min-w-4 bg-sn-science-blue text-white flex items-center justify-center rounded-full text-[10px]">
{{ step.attributes.results.length }}
</span>
</button>
</template>
<template v-slot:flyout>
<a v-for="result in step.attributes.results"
:key="result.id"
:title="result.name"
:href="resultUrl(result.id)"
class="py-2.5 px-3 hover:bg-sn-super-light-grey cursor-pointer block hover:no-underline text-sn-blue truncate"
>
{{ result.name }}
</a>
<hr class="my-0">
<div class="py-2.5 px-3 hover:bg-sn-super-light-grey cursor-pointer text-sn-blue"
@click="this.openLinkResultsModal = true; $refs.linkedResultsDropdown.closeMenu()">
{{ i18n.t('protocols.steps.manage_links') }}
</div>
</template>
</GeneralDropdown>
<a href=" #"
v-if="!inRepository"
ref="comments"
class="open-comments-sidebar btn icon-btn btn-light"
@ -181,6 +208,12 @@
@close="openFormSelectModal = false"
@submit="createElement('form_response', null, null, $event); openFormSelectModal = false"
/>
<LinkResultsModal
v-if="openLinkResultsModal"
:step="step"
@updateStep="updateLinkedResults"
@close="openLinkResultsModal = false"
/>
</div>
</template>
@ -197,9 +230,11 @@
import Checklist from '../shared/content/checklist.vue'
import FormResponse from '../shared/content/form_response.vue'
import deleteStepModal from './modals/delete_step.vue'
import LinkResultsModal from './modals/link_results.vue'
import Attachments from '../shared/content/attachments.vue'
import ReorderableItemsModal from '../shared/reorderable_items_modal.vue'
import MenuDropdown from '../shared/menu_dropdown.vue'
import GeneralDropdown from '../shared/general_dropdown.vue'
import ContentToolbar from '../shared/content/content_toolbar.vue'
import CustomWellPlateModal from '../shared/content/modal/custom_well_plate_modal.vue'
@ -211,6 +246,10 @@
import StorageUsage from '../shared/content/attachments/storage_usage.vue'
import axios from '../../packs/custom_axios';
import {
my_module_results_path,
} from '../../routes.js';
export default {
name: 'StepContainer',
props: {
@ -256,6 +295,7 @@
inlineEditError: null,
customWellPlate: false,
openFormSelectModal: false,
openLinkResultsModal: false,
wellPlateOptions: [
{ text: I18n.t('protocols.steps.insert.well_plate_options.custom'),
emit: 'create:custom_well_plate',
@ -305,7 +345,9 @@
ContentToolbar,
CustomWellPlateModal,
SelectFormModal,
FormResponse
FormResponse,
LinkResultsModal,
GeneralDropdown
},
created() {
this.loadAttachments();
@ -751,7 +793,16 @@
}).fail(() => {
HelperModule.flashAlertMsg(this.i18n.t('protocols.steps.step_duplication_failed'), 'danger');
});
}
},
updateLinkedResults(results) {
this.$emit('step:update', {
results: results,
position: this.step.attributes.position
})
},
resultUrl(result_id) {
return my_module_results_path({my_module_id: this.step.attributes.my_module_id, result_id: result_id})
},
}
}
</script>

View file

@ -0,0 +1,136 @@
<template>
<div ref="modal" class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<form @submit.prevent="submit">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<i class="sn-icon sn-icon-close"></i>
</button>
<h4 class="modal-title truncate !block">
{{ i18n.t('my_modules.results.modals.link_steps.title') }}
</h4>
</div>
<div v-if="steps.length > 0" class="modal-body">
<p>
{{ i18n.t('my_modules.results.modals.link_steps.description') }}
</p>
<div class="mt-6">
<label class="sci-label">{{ i18n.t('my_modules.results.modals.link_steps.steps_label') }}</label>
<SelectDropdown
:options="steps"
:value="selectedSteps"
@change="changeSteps"
:multiple="true"
:withCheckboxes="true"
:placeholder="i18n.t('my_modules.results.modals.link_steps.placeholder')" />
</div>
</div>
<div v-else class="modal-body">
<p>
{{ i18n.t('my_modules.results.modals.link_steps.empty_description') }}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">
{{ i18n.t('general.cancel') }}
</button>
<template v-if="steps.length > 0">
<button v-if="result.attributes.steps.length == 0" type="submit" class="btn btn-primary" @click="linkSteps">
{{ i18n.t('my_modules.results.modals.link_steps.link_steps') }}
</button>
<button v-else type="submit" class="btn btn-primary" @click="linkSteps">
{{ i18n.t('general.save') }}
</button>
</template>
<template v-else>
<a :href="protocolPageUrl" class="btn btn-primary">
{{ i18n.t('my_modules.results.modals.link_steps.go_to_protocol') }}
</a>
</template>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
/* global HelperModule I18n */
import SelectDropdown from '../../shared/select_dropdown.vue';
import axios from '../../../packs/custom_axios.js';
import modalMixin from '../../shared/modal_mixin.js';
import {
list_steps_path,
protocols_my_module_path,
link_steps_step_results_path
} from '../../../routes.js';
export default {
name: 'LinkStepsModal',
props: {
result: {
type: Object,
required: true
},
protocolId: {
type: Number,
required: true
}
},
mixins: [modalMixin],
components: {
SelectDropdown
},
created() {
this.selectedSteps = this.result.attributes.steps.map((step) => step.id);
this.loadSteps();
},
data() {
return {
steps: [],
selectedSteps: []
};
},
computed: {
stepsListUrl() {
return list_steps_path({ protocol_id: this.protocolId });
},
protocolPageUrl() {
return protocols_my_module_path({ id: this.result.attributes.my_module_id });
},
stepsLinkUrl() {
return link_steps_step_results_path({ format: 'json' });
}
},
methods: {
changeSteps(value) {
this.selectedSteps = value;
},
linkSteps() {
axios.post(
this.stepsLinkUrl,
{
step_ids: this.selectedSteps,
result_ids: this.result.id
}
)
.then((response) => {
this.$emit('close');
this.$emit('updateResult', response.data.steps);
HelperModule.flashAlertMsg(I18n.t('protocols.steps.modals.link_results.success'), 'success');
}).catch(() => {
HelperModule.flashAlertMsg(I18n.t('protocols.steps.modals.link_results.error'), 'danger');
this.$emit('close');
});
},
loadSteps() {
axios.get(this.stepsListUrl)
.then((response) => {
this.steps = response.data;
});
}
}
};
</script>

View file

@ -65,7 +65,34 @@
:data-object-type="result.attributes.type"
tabindex="0"
></span> <!-- Hidden element to support legacy code -->
<button v-if="result.attributes.steps.length == 0" class="btn btn-light icon-btn" @click="this.openLinkStepsModal = true">
{{ i18n.t('my_modules.results.link_to_step') }}
</button>
<GeneralDropdown v-else ref="linkedStepsDropdown" position="right">
<template v-slot:field>
<button class="btn btn-light icon-btn">
{{ i18n.t('my_modules.results.link_to_step') }}
<span class="absolute top-1 -right-1 h-4 min-w-4 bg-sn-science-blue text-white flex items-center justify-center rounded-full text-[10px]">
{{ result.attributes.steps.length }}
</span>
</button>
</template>
<template v-slot:flyout>
<a v-for="step in result.attributes.steps"
:key="step.id"
:title="step.name"
:href="protocolUrl(step.id)"
class="py-2.5 px-3 hover:bg-sn-super-light-grey cursor-pointer block hover:no-underline text-sn-blue truncate"
>
{{ step.name }}
</a>
<hr class="my-0">
<div class="py-2.5 px-3 hover:bg-sn-super-light-grey cursor-pointer text-sn-blue"
@click="this.openLinkStepsModal = true; $refs.linkedStepsDropdown.closeMenu()">
{{ i18n.t('protocols.steps.manage_links') }}
</div>
</template>
</GeneralDropdown>
<a href="#"
ref="comments"
class="open-comments-sidebar btn icon-btn btn-light"
@ -148,6 +175,13 @@
@cancel="closeCustomWellPlateModal"
@create:table="(...args) => this.createElement('table', ...args)"
/>
<LinkStepsModal
v-if="openLinkStepsModal"
:result="result"
:protocolId="protocolId"
@updateResult="updateLinkedSteps"
@close="openLinkStepsModal = false"
/>
</div>
</div>
</div>
@ -161,7 +195,9 @@ import ResultText from '../shared/content/text.vue';
import Attachments from '../shared/content/attachments.vue';
import InlineEdit from '../shared/inline_edit.vue';
import MenuDropdown from '../shared/menu_dropdown.vue';
import GeneralDropdown from '../shared/general_dropdown.vue';
import deleteResultModal from './delete_result.vue';
import LinkStepsModal from './modals/link_steps.vue'
import ContentToolbar from '../shared/content/content_toolbar';
import CustomWellPlateModal from '../shared/content/modal/custom_well_plate_modal.vue'
@ -170,12 +206,16 @@ import WopiFileModal from '../shared/content/attachments/mixins/wopi_file_modal.
import OveMixin from '../shared/content/attachments/mixins/ove.js';
import UtilsMixin from '../mixins/utils.js';
import StorageUsage from '../shared/content/attachments/storage_usage.vue';
import {
protocols_my_module_path,
} from '../../routes.js';
export default {
name: 'Results',
props: {
result: { type: Object, required: true },
resultToReload: { type: Number, required: false },
protocolId: { type: Number, required: false },
activeDragResult: {
required: false
},
@ -193,6 +233,7 @@ export default {
showFileModal: false,
dragingFile: false,
customWellPlate: false,
openLinkStepsModal: false,
wellPlateOptions: [
{ text: I18n.t('protocols.steps.insert.well_plate_options.custom'), emit: 'create:custom_well_plate'},
{ text: I18n.t('protocols.steps.insert.well_plate_options.32_x_48'), emit: 'create:table', params: [32, 48] },
@ -219,7 +260,9 @@ export default {
deleteResultModal,
StorageUsage,
ContentToolbar,
CustomWellPlateModal
CustomWellPlateModal,
LinkStepsModal,
GeneralDropdown
},
watch: {
resultToReload() {
@ -576,7 +619,15 @@ export default {
axios.patch(this.urls.update_url, { result: { name } }).then((_) => {
this.$emit('updated');
});
}
},
updateLinkedSteps(steps) {
this.$emit('result:update', this.result.id,{
steps: steps
})
},
protocolUrl(step_id) {
return protocols_my_module_path({ id: this.result.attributes.my_module_id }, { step_id: step_id })
},
}
};
</script>

View file

@ -27,6 +27,8 @@
:resultToReload="resultToReload"
:activeDragResult="activeDragResult"
:userSettingsUrl="userSettingsUrl"
:protocolId="protocolId"
@result:update="updateResult"
@result:elements:loaded="resultToReload = null; elementsLoaded++"
@result:move_element="reloadResult"
@result:attachments:loaded="resultToReload = null; attachmentsLoaded++"
@ -74,7 +76,8 @@ export default {
archived: { type: String, required: true },
active_url: { type: String, required: true },
archived_url: { type: String, required: true },
userSettingsUrl: { type: String, required: false }
userSettingsUrl: { type: String, required: false },
protocolId: { type: Number, required: false }
},
data() {
return {
@ -238,6 +241,13 @@ export default {
axios.put(this.userSettingsUrl, { settings: [settings] });
},
updateResult(id, attributes) {
const resultIndex = this.results.findIndex((result) => result.id === id);
this.results[resultIndex].attributes = {
...this.results[resultIndex].attributes,
...attributes
};
},
removeResult(result_id) {
this.results = this.results.filter((r) => r.id != result_id);
},

View file

@ -10,7 +10,7 @@ class StepSerializer < ActiveModel::Serializer
attributes :name, :position, :completed, :attachments_manageble, :urls, :assets_view_mode,
:marvinjs_enabled, :marvinjs_context, :created_by, :created_at, :assets_order,
:wopi_enabled, :wopi_context, :comments_count, :unseen_comments, :storage_limit,
:type, :open_vector_editor_context, :collapsed, :results
:type, :open_vector_editor_context, :collapsed, :my_module_id, :results
def collapsed
step_states = @instance_options[:user].settings.fetch('task_step_states', {})
@ -27,6 +27,10 @@ class StepSerializer < ActiveModel::Serializer
end
end
def my_module_id
object.my_module&.id
end
def type
'Step'
end

View file

@ -20,6 +20,7 @@
<div id="results" data-behaviour="vue">
<results url="<%= my_module_results_url(@my_module, view_mode: params[:view_mode]) %>"
active_url="<%= my_module_results_url(@my_module) %>"
protocol-id="<%= @my_module.protocol.id %>"
archived_url="<%= my_module_results_url(@my_module, view_mode: :archived) %>"
can-create=<%= can_create_results?(@my_module) && !(params[:view_mode] == 'archived') %>
archived=<%= params[:view_mode] == 'archived' %>>

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
json.array! @results do |r|
json.array! [r.id, r.name]
end

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
json.array! @steps do |r|
json.array! [
r.id,
r.name,
{
position: r.position
}
]
end

View file

@ -1603,6 +1603,17 @@ en:
info_tab: "Info"
comments_tab: "Comments"
comment_title: "%{user} at %{time}:"
link_to_step: "Link to step"
modals:
link_steps:
title: 'Link result to protocol steps'
description: 'You can link result to multipple steps'
empty_description: 'No step found. Add a step first to enable linking with a result.'
steps_label: 'Protocol steps'
placeholder: 'Select steps'
link_steps: 'Link steps'
go_to_protocol: 'Go to Protocol'
step_label: "Step %{position} - %{name}"
insert:
button: 'Insert content'
title: 'Insert result content'
@ -3980,6 +3991,7 @@ en:
component_duplication_failed: "Step content could not be duplicated, try again later."
timestamp: "Created on %{date} by %{user}"
timestamp_iso_html: "Created on <span class='iso-formatted-date'>%{date}</span> by %{user}"
manage_links: "Manage links"
status:
complete: "Mark as done"
uncomplete: "Unmark as done"
@ -4067,6 +4079,16 @@ en:
confirm: 'Yes, delete them'
reorder_elements:
title: 'Rearrange step %{step_position} content'
link_results:
title: 'Link results to this step'
description: 'You can link multiple results to a step'
empty_description: 'No results found. Add a result first to enable linking with a step.'
result_label: 'Result'
placeholder: 'Select results'
link_results: 'Link results'
go_to_results: 'Go to Results'
success: 'Step and result links successfully updated.'
error: 'There was a problem with linking result to the step.'
custom_well_plate:
title: 'Insert custom well plate'
name_label: 'Well plate name'

View file

@ -460,9 +460,10 @@ Rails.application.routes.draw do
get 'experiments/:experiment_id/table', to: 'my_modules#index'
get 'experiments/:experiment_id/modules', to: 'my_modules#index', as: :my_modules
resources :step_results, only: %i(destroy) do
resources :step_results, only: [] do
collection do
post :create
post :link_results
post :link_steps
end
end
@ -564,6 +565,9 @@ Rails.application.routes.draw do
get 'users/edit', to: 'user_my_modules#index_edit'
resources :results, only: %i(index show create update destroy) do
collection do
get :list
end
member do
get :elements
get :assets
@ -642,6 +646,9 @@ Rails.application.routes.draw do
post 'update_asset_view_mode'
post 'duplicate'
end
collection do
get :list
end
end
# tinyMCE image uploader endpoint