diff --git a/app/api/api.rb b/app/api/api.rb index 9cbb3df192..6e1c11a8ff 100644 --- a/app/api/api.rb +++ b/app/api/api.rb @@ -129,7 +129,7 @@ def to_json_camel_case(val) ], 'reactions' => %w[ name short_label status conditions rxno content temperature duration - role purification tlc_solvents tlc_description rf_value dangerous_products + role reaction_type purification tlc_solvents tlc_description rf_value dangerous_products plain_text_description plain_text_observation ], 'wellplates' => %w[name short_label readout_titles content plain_text_description], diff --git a/app/api/chemotion/reaction_api.rb b/app/api/chemotion/reaction_api.rb index 8c91762537..ed307afb8c 100644 --- a/app/api/chemotion/reaction_api.rb +++ b/app/api/chemotion/reaction_api.rb @@ -152,12 +152,15 @@ class ReactionAPI < Grape::API optional :purification, type: [String] optional :dangerous_products, type: [String] optional :conditions, type: String + optional :ph_operator, type: String + optional :ph_value, type: String optional :tlc_solvents, type: String optional :solvent, type: String optional :tlc_description, type: String optional :rf_value, type: String optional :temperature, type: Hash optional :status, type: String + optional :reaction_type, type: String, values: Reaction.reaction_types.keys optional :role, type: String optional :origin, type: Hash optional :reaction_svg_file, type: String @@ -233,12 +236,15 @@ class ReactionAPI < Grape::API optional :purification, type: [String] optional :dangerous_products, type: [String] optional :conditions, type: String + optional :ph_operator, type: String + optional :ph_value, type: String optional :tlc_solvents, type: String optional :solvent, type: String optional :tlc_description, type: String optional :rf_value, type: String optional :temperature, type: Hash optional :status, type: String + optional :reaction_type, type: String, values: Reaction.reaction_types.keys optional :role, type: String optional :origin, type: Hash optional :reaction_svg_file, type: String diff --git a/app/api/chemotion/reaction_svg_api.rb b/app/api/chemotion/reaction_svg_api.rb index cade1f4372..77d8af094a 100644 --- a/app/api/chemotion/reaction_svg_api.rb +++ b/app/api/chemotion/reaction_svg_api.rb @@ -11,15 +11,22 @@ class ReactionSvgAPI < Grape::API optional :duration, type: String, desc: 'duration which is placed under the reaction-arrow' requires :solvents, type: Array, desc: 'solvents which is placed under the reaction-arrow' optional :conditions, type: String, desc: 'conditions which is placed under the reaction-arrow' + optional :products_only, type: Boolean, default: false + optional :show_yield, type: Boolean, default: true end post do paths = params[:materials_svg_paths] - composer = SVG::ReactionComposer.new(paths, temperature: params[:temperature], - solvents: params[:solvents], - duration: params[:duration], - conditions: params[:conditions], - show_yield: true) - { reaction_svg: composer.compose_reaction_svg } + composer_class = params[:products_only] ? SVG::ProductsComposer : SVG::ReactionComposer + composer_options = { + temperature: params[:temperature], + solvents: params[:solvents], + duration: params[:duration], + conditions: params[:conditions], + show_yield: params[:show_yield], + } + + composer = composer_class.new(paths, composer_options) + { reaction_svg: composer.compose_svg } end end end diff --git a/app/api/entities/reaction_entity.rb b/app/api/entities/reaction_entity.rb index 8304bb3c8d..f8cdd00a36 100644 --- a/app/api/entities/reaction_entity.rb +++ b/app/api/entities/reaction_entity.rb @@ -25,6 +25,8 @@ class ReactionEntity < ApplicationEntity with_options(anonymize_below: 10) do expose! :code_log, anonymize_with: nil, using: 'Entities::CodeLogEntity' expose! :conditions, unless: :displayed_in_list + expose! :ph_operator, unless: :displayed_in_list + expose! :ph_value, unless: :displayed_in_list expose! :container, anonymize_with: nil, using: 'Entities::ContainerEntity' expose! :dangerous_products, anonymize_with: [], unless: :displayed_in_list expose! :duration, unless: :displayed_in_list @@ -37,6 +39,7 @@ class ReactionEntity < ApplicationEntity expose! :rinchi_short_key expose! :rinchi_web_key expose! :rxno + expose! :reaction_type expose! :segments, anonymize_with: [], using: 'Labimotion::SegmentEntity' expose! :short_label expose! :solvent, unless: :displayed_in_list diff --git a/app/assets/stylesheets/legacy/reaction.scss b/app/assets/stylesheets/legacy/reaction.scss index 498e7a7ebf..de6d289861 100644 --- a/app/assets/stylesheets/legacy/reaction.scss +++ b/app/assets/stylesheets/legacy/reaction.scss @@ -113,6 +113,89 @@ table.reaction-scheme-solvent { } } +.reaction-details-toolbar { + width: 100%; + + &__left { + flex: 1 1 auto; + min-width: 0; + } + + &__right { + margin-left: auto; + flex: 0 0 auto; + } + + &__group { + .form-label { + font-weight: 600; + } + } + + &__group--status { + min-width: 220px; + } +} + +.reaction-details-header { + &__left { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex-wrap: nowrap; + overflow: hidden; + } + + &__title { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; + flex-wrap: nowrap; + flex: 0 1 auto; + } + + &__title-prefix { + flex: 0 0 auto; + white-space: nowrap; + } + + &__title-meta { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + white-space: nowrap; + } + + &__title-text { + min-width: 0; + font-weight: 400; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + + &--empty { + color: #6c757d; + } + } + + &__title-input { + width: clamp(120px, 16vw, 220px); + min-width: 0; + flex: 0 1 clamp(120px, 16vw, 220px); + + &:focus, + &:focus-visible { + border-color: $input-border-color; + box-shadow: none; + outline: 0; + } + } +} + /* Safari 4.0 - 8.0 */ @-webkit-keyframes reaction-status-color-change { from { diff --git a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetails.js b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetails.js index eb2d4283b3..aea3f1fe13 100644 --- a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetails.js +++ b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetails.js @@ -5,9 +5,10 @@ import React, { Component, createRef } from 'react'; import Aviator from 'aviator'; import PropTypes from 'prop-types'; import { - Button, Tabs, Tab, OverlayTrigger, Tooltip, ButtonToolbar, Dropdown, Modal, Overlay + Button, Tabs, Tab, OverlayTrigger, Tooltip, ButtonToolbar, Dropdown, Modal, Overlay, Form } from 'react-bootstrap'; import { findIndex, isEmpty } from 'lodash'; +import { Select } from 'src/components/common/Select'; import ElementCollectionLabels from 'src/apps/mydb/elements/labels/ElementCollectionLabels'; import ElementResearchPlanLabels from 'src/apps/mydb/elements/labels/ElementResearchPlanLabels'; @@ -61,6 +62,19 @@ import ReactionSchemeGraphic from 'src/apps/mydb/elements/details/reactions/Reac import WeightPercentageReactionActions from 'src/stores/alt/actions/WeightPercentageReactionActions'; import isEqual from 'lodash/isEqual'; import DocumentationButton from 'src/components/common/DocumentationButton'; +import { statusOptions } from 'src/components/staticDropdownOptions/options'; +import Reaction from 'src/models/Reaction'; + +const formatReactionTypeOption = (option, { context }) => ( + context === 'value' + ? ( + + + {`Reaction type: ${option.label}`} + + ) + : option.label +); const handleProductClick = (product) => { const uri = Aviator.getCurrentURI(); @@ -98,6 +112,8 @@ export default class ReactionDetails extends Component { currentUser: (UserStore.getState() && UserStore.getState().currentUser) || {}, reactionSvgVersion: 0, // Bumped when graphic is updated so shouldComponentUpdate sees a state change (we mutate reaction in place) isRefreshingGraphic: false, + isEditingHeaderName: false, + headerNameDraft: reaction.name || '', }; this.onUIStoreChange = this.onUIStoreChange.bind(this); @@ -110,13 +126,20 @@ export default class ReactionDetails extends Component { this.closeWtInfoModal = this.closeWtInfoModal.bind(this); this.confirmSchemeChange = this.confirmSchemeChange.bind(this); this.cancelSchemeChange = this.cancelSchemeChange.bind(this); + this.openHeaderNameEditor = this.openHeaderNameEditor.bind(this); + this.handleHeaderNameDraftChange = this.handleHeaderNameDraftChange.bind(this); + this.commitHeaderNameChange = this.commitHeaderNameChange.bind(this); + this.cancelHeaderNameChange = this.cancelHeaderNameChange.bind(this); this.state.showWtInfoModal = false; this.state.showSchemeChangeConfirm = false; this.state.pendingSchemeType = null; this.isUpdatingGraphic = false; // Flag to prevent infinite loops this.pendingGraphicReaction = null; // Queued reaction when update requested during in-flight fetch this.schemeDropdownRef = createRef(); - if (!reaction.reaction_svg_file) { + this.headerNameInputRef = createRef(); + // If reaction type is Interaction, always regenerate the scheme preview on load because + // they intentionally use the products-only graphic, even if an older SVG exists. + if (!reaction.reaction_svg_file || reaction.isInteractionReaction()) { this.updateGraphic(); } } @@ -129,6 +152,25 @@ export default class ReactionDetails extends Component { this.setState({ showWtInfoModal: false }); } + renderReactionTypeSelect(reaction) { + const selectedReactionType = reaction.reaction_type || 'standard'; + + return ( + + value === reaction.status)} + isDisabled={!permitOn(reaction) || reaction.isMethodDisabled('status')} + onChange={(option) => { + const wrappedEvent = { target: { value: option?.value || null } }; + this.handleInputChange('status', wrappedEvent); + }} + /> + + { !reaction.isNew && diff --git a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsMainProperties.js b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsMainProperties.js index f8e71adb42..6bf34b7c0f 100644 --- a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsMainProperties.js +++ b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsMainProperties.js @@ -9,10 +9,7 @@ import { Row, Form } from 'react-bootstrap'; -import { Select } from 'src/components/common/Select'; -import uuid from 'uuid'; import Reaction from 'src/models/Reaction'; -import { statusOptions } from 'src/components/staticDropdownOptions/options'; import LineChartContainer from 'src/components/lineChart/LineChartContainer'; import EditableTable from 'src/components/lineChart/EditableTable'; import { permitOn } from 'src/components/common/uis'; @@ -30,8 +27,8 @@ export default class ReactionDetailsMainProperties extends Component { } updateTemperature(newData) { - const { reaction: { temperature } } = this.props; - this.props.onInputChange('temperatureData', { ...temperature, data: newData }); + const { reaction: { temperature }, onInputChange } = this.props; + onInputChange('temperatureData', { ...temperature, data: newData }); } toggleTemperatureChart() { @@ -40,59 +37,49 @@ export default class ReactionDetailsMainProperties extends Component { } changeUnit() { - const { reaction: { temperature } } = this.props; + const { reaction: { temperature }, onInputChange } = this.props; const units = Reaction.temperature_unit; const index = units.indexOf(temperature.valueUnit); const unit = units[(index + 1) % units.length]; - this.props.onInputChange('temperatureUnit', unit); + onInputChange('temperatureUnit', unit); } render() { - const { reaction, onInputChange } = this.props; + const { + reaction, + onInputChange, + leadingField, + leadingFieldColSize, + temperatureColSize, + showSchemeFields, + phField, + vesselSizeField, + durationField, + reactionVolumeField, + } = this.props; const { temperature } = reaction; const { showTemperatureChart } = this.state; + const rowClassName = showSchemeFields ? 'mt-3 mb-0' : 'my-3'; return ( <> - - - - Name - onInputChange('name', event)} - /> - - - - - Status - ; + } return this.yieldOrConversionRate(material); } const isSbmm = isSbmmSample(material); diff --git a/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/MaterialGroup.js b/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/MaterialGroup.js index 338108aadd..0e23ef90ff 100644 --- a/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/MaterialGroup.js +++ b/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/MaterialGroup.js @@ -178,11 +178,13 @@ function GeneralMaterialGroup({ switchEquiv, lockEquivColumn, displayYieldField, switchYield }) { const isReactants = materialGroup === 'reactants'; + const isInteractionReaction = reaction.isInteractionReaction(); + const isInteractionProducts = isInteractionReaction && materialGroup === 'products'; const groupHeaders = { ...headers }; let reagentDd = null; if (isReactants) { - groupHeaders.group = 'Reactants'; + groupHeaders.group = isInteractionReaction ? 'Additives' : 'Reactants'; const reagentList = Object.keys(reagents_kombi).map((x) => ({ label: x, @@ -224,6 +226,10 @@ function GeneralMaterialGroup({ ); } + if (materialGroup === 'starting_materials' && isInteractionReaction) { + groupHeaders.group = 'Guest and host'; + } + const yieldConversionRateFields = () => { const conversionText = ( <> @@ -259,7 +265,9 @@ function GeneralMaterialGroup({ if (materialGroup === 'products') { groupHeaders.group = 'Products'; - groupHeaders.eq = yieldConversionRateFields(); + if (!isInteractionReaction) { + groupHeaders.eq = yieldConversionRateFields(); + } } const specialRefTHead = reaction.weight_percentage ? ( @@ -332,15 +340,17 @@ function GeneralMaterialGroup({
{groupHeaders.purity}
{showLoadingColumn &&
{groupHeaders.loading}
}
{groupHeaders.concn}
-
- {groupHeaders.eq} - {materialGroup === 'starting_materials' && ( - - )} -
+ {!isInteractionProducts && ( +
+ {groupHeaders.eq} + {materialGroup === 'starting_materials' && ( + + )} +
+ )}
diff --git a/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/ReactionDetailsDuration.js b/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/ReactionDetailsDuration.js index 2e0f954876..a045d07779 100644 --- a/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/ReactionDetailsDuration.js +++ b/app/javascript/src/apps/mydb/elements/details/reactions/schemeTab/ReactionDetailsDuration.js @@ -41,11 +41,61 @@ export default class ReactionDetailsDuration extends Component { } render() { - const { reaction } = this.props; + const { + reaction, + onInputChange, + isInteractionReaction, + inlineInteractionField, + } = this.props; const durationCalc = reaction && reaction.durationCalc(); const timePlaceholder = 'DD/MM/YYYY hh:mm:ss'; + + if (isInteractionReaction) { + // Interaction reactions use a single incubation-time input instead of + // the start/stop/duration workflow used for standard reactions. + const interactionField = ( + + Time (incubation) + + this.handleDurationChange(event)} + /> + switch duration unit} + > + + + + + ); + + if (inlineInteractionField) { + return interactionField; + } + + return ( + + + {interactionField} + + + ); + } + return ( - + Start @@ -55,11 +105,15 @@ export default class ReactionDetailsDuration extends Component { value={reaction.timestamp_start || ''} disabled={!permitOn(reaction) || reaction.isMethodDisabled('timestamp_start') || reaction.gaseous} placeholder={timePlaceholder} - onChange={event => this.props.onInputChange('timestampStart', event)} + onChange={event => onInputChange('timestampStart', event)} /> -