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 (
+
+
+ );
+ }
+
componentDidMount() {
const { reaction } = this.props;
const { currentUser } = this.state;
@@ -160,9 +202,16 @@ export default class ReactionDetails extends Component {
const prevSvg = prevState.reaction?.reaction_svg_file;
const hasPrevSvg = prevSvg !== undefined && prevSvg !== null && String(prevSvg).trim() !== '';
let stateUpdate = { reaction };
+ if (!this.state.isEditingHeaderName) {
+ stateUpdate = { ...stateUpdate, headerNameDraft: reaction.name || '' };
+ }
if (isSameReaction && hasPrevSvg) {
reaction.reaction_svg_file = prevSvg;
- stateUpdate = { reaction, reactionSvgVersion: (this.state.reactionSvgVersion || 0) + 1 };
+ stateUpdate = {
+ ...stateUpdate,
+ reaction,
+ reactionSvgVersion: (this.state.reactionSvgVersion || 0) + 1
+ };
}
this.setState(stateUpdate, () => {
if (isSameReaction && hasPrevSvg) {
@@ -200,9 +249,12 @@ export default class ReactionDetails extends Component {
const nextShowSchemeChangeConfirm = nextState.showSchemeChangeConfirm;
const nextShowWtInfoModal = nextState.showWtInfoModal;
const nextReactionSvgVersion = nextState.reactionSvgVersion;
+ const nextIsEditingHeaderName = nextState.isEditingHeaderName;
+ const nextHeaderNameDraft = nextState.headerNameDraft;
const {
reaction: reactionFromCurrentState, activeTab, visible, activeAnalysisTab,
- showSchemeChangeConfirm, showWtInfoModal, reactionSvgVersion
+ showSchemeChangeConfirm, showWtInfoModal, reactionSvgVersion,
+ isEditingHeaderName, headerNameDraft
} = this.state;
return (
reactionFromNextProps.id !== reactionFromCurrentState.id
@@ -215,6 +267,8 @@ export default class ReactionDetails extends Component {
|| nextShowSchemeChangeConfirm !== showSchemeChangeConfirm
|| nextShowWtInfoModal !== showWtInfoModal
|| nextReactionSvgVersion !== reactionSvgVersion
+ || nextIsEditingHeaderName !== isEditingHeaderName
+ || nextHeaderNameDraft !== headerNameDraft
);
}
@@ -249,6 +303,45 @@ export default class ReactionDetails extends Component {
}
}
+ openHeaderNameEditor() {
+ const { reaction } = this.state;
+ if (!permitOn(reaction) || reaction.isMethodDisabled('name')) {
+ return;
+ }
+
+ this.setState({
+ isEditingHeaderName: true,
+ headerNameDraft: reaction.name || '',
+ }, () => {
+ this.headerNameInputRef.current?.focus();
+ this.headerNameInputRef.current?.select();
+ });
+ }
+
+ handleHeaderNameDraftChange(event) {
+ this.setState({ headerNameDraft: event.target.value });
+ }
+
+ commitHeaderNameChange() {
+ const { reaction, headerNameDraft } = this.state;
+ const nextName = headerNameDraft.trim();
+
+ this.setState({ isEditingHeaderName: false });
+ if (nextName === (reaction.name || '')) {
+ return;
+ }
+
+ this.handleInputChange('name', { target: { value: nextName } });
+ }
+
+ cancelHeaderNameChange() {
+ const { reaction } = this.state;
+ this.setState({
+ isEditingHeaderName: false,
+ headerNameDraft: reaction.name || '',
+ });
+ }
+
handleInputChange(type, event) {
let value;
if (
@@ -264,9 +357,12 @@ export default class ReactionDetails extends Component {
|| type === 'vesselSizeUnit'
|| type === 'gaseous'
|| type === 'conditions'
+ || type === 'phOperator'
+ || type === 'phValue'
|| type === 'volume'
|| type === 'useReactionVolumeForConcentration'
|| type === 'weight_percentage'
+ || type === 'reactionType'
|| type === 'default'
) {
value = event;
@@ -280,6 +376,13 @@ export default class ReactionDetails extends Component {
const { newReaction, options } = setReactionByType(reaction, type, value);
+ if (type === 'reactionType' && value === 'interaction') {
+ // Interaction reactions always use the default scheme internals, so clear
+ // any residual gas or weight-percentage state before re-rendering the tab.
+ this.resetWeightPercentagedependencies(newReaction);
+ this.recalculateEquivalentsForMaterials(newReaction);
+ }
+
// Update gas phase store synchronously for vessel size changes
// to ensure store is updated before gas calculations run during render
if (type === 'vesselSizeAmount' || type === 'vesselSizeUnit') {
@@ -399,6 +502,8 @@ export default class ReactionDetails extends Component {
reactionHeader(reaction) {
const titleTooltip = formatTimeStampsOfElement(reaction || {});
+ const titlePrefix = reaction.short_label || '';
+ const { isEditingHeaderName, headerNameDraft } = this.state;
const colLabel = !reaction.isNew && (
@@ -408,15 +513,57 @@ export default class ReactionDetails extends Component {
);
- return (
-
-
-
{titleTooltip}}>
-
-
- {reaction.title()}
+ const titleContent = (
+
+ {titleTooltip}}>
+
+
+ {titlePrefix && (
+ {titlePrefix}
+ )}
+
+
+ {isEditingHeaderName ? (
+
{
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.commitHeaderNameChange();
+ } else if (event.key === 'Escape') {
+ this.cancelHeaderNameChange();
+ }
+ }}
+ />
+ ) : (
+ Double-click to edit reaction name}
+ >
+
+ {reaction.name || 'Reaction name'}
+ )}
+
+ );
+
+ return (
+
+
+ {titleContent}
{colLabel}
{rsPlanLabel}
@@ -592,13 +739,17 @@ export default class ReactionDetails extends Component {
if (/^[\-|\d]\d*\.{0,1}\d{0,2}$/.test(temperature)) {
temperature = `${temperature} ${reaction.temperature.valueUnit}`;
}
+ const productsOnly = reaction.isInteractionReaction();
+ const showYield = !productsOnly;
ReactionSvgFetcher.fetchByMaterialsSvgPaths(
materialsSvgPaths,
temperature,
solvents,
reaction.duration,
- reaction.conditions
+ reaction.conditions,
+ productsOnly,
+ showYield
).then((result) => {
if (result && result.reaction_svg && result.reaction_svg !== reaction.reaction_svg_file) {
// Update reaction_svg_file and state - image will reload automatically via ReactionSchemeGraphic useEffect
@@ -818,6 +969,7 @@ export default class ReactionDetails extends Component {
const {
reaction, visible, activeTab, showSchemeChangeConfirm
} = this.state;
+ const isInteractionReaction = reaction.isInteractionReaction();
this.updateReactionVesselSize(reaction);
let schemeType = 'Default';
let documentationLink;
@@ -849,68 +1001,75 @@ export default class ReactionDetails extends Component {
const tabContentsMap = {
scheme: (
-
-
-
-
-
- Current Scheme:
- {schemeType}
-
-
-
- this.handleReactionSchemeChange('default')}
- >
- Default Scheme
-
- this.handleReactionSchemeChange('gaseous')}
- >
- Gas Scheme
-
- this.handleReactionSchemeChange('weight_percentage')}
- >
- Weight Percentage Scheme
-
-
-
-
this.schemeDropdownRef.current}
- show={showSchemeChangeConfirm}
- placement="bottom"
- rootClose
- onHide={() => this.cancelSchemeChange()}
- >
-
- Any Assigned Weight percentage reference and wt% values in wt% fields
-
- of materials will be deleted.
-
- Switch scheme?
-
-
-
-
-
-
-
+
+
+ {this.renderReactionTypeSelect(reaction)}
+ {!isInteractionReaction && (
+
+
+
+
+ Current Scheme:
+ {schemeType}
+
+
+
+ this.handleReactionSchemeChange('default')}
+ >
+ Default Scheme
+
+ this.handleReactionSchemeChange('gaseous')}
+ >
+ Gas Scheme
+
+ this.handleReactionSchemeChange('weight_percentage')}
+ >
+ Weight Percentage Scheme
+
+
+
+ )}
+
+ {!isInteractionReaction && (
+
this.schemeDropdownRef.current}
+ show={showSchemeChangeConfirm}
+ placement="bottom"
+ rootClose
+ onHide={() => this.cancelSchemeChange()}
+ >
+
+ Any Assigned Weight percentage reference and wt% values in wt% fields
+
+ of materials will be deleted.
+
+ Switch scheme?
+
+
+
+
+
+
+
+ )}
{reaction.weight_percentage && (
<>
)}
+
+
+
+
{
!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
-
-
-
+
+ {leadingField && (
+
+ {leadingField}
+
+ )}
+
Temperature
- Show temperature chart
- )}>
+ Show temperature chart
+ )}
+ >
+ {showSchemeFields && (
+ <>
+
+ {phField}
+
+
+ {durationField || vesselSizeField}
+
+
+ {reactionVolumeField}
+
+ >
+ )}
{showTemperatureChart && (
@@ -146,10 +146,26 @@ export default class ReactionDetailsMainProperties extends Component {
ReactionDetailsMainProperties.propTypes = {
// eslint-disable-next-line react/forbid-prop-types
reaction: PropTypes.object,
- onInputChange: PropTypes.func
+ onInputChange: PropTypes.func,
+ leadingField: PropTypes.node,
+ leadingFieldColSize: PropTypes.number,
+ temperatureColSize: PropTypes.number,
+ showSchemeFields: PropTypes.bool,
+ phField: PropTypes.node,
+ vesselSizeField: PropTypes.node,
+ durationField: PropTypes.node,
+ reactionVolumeField: PropTypes.node,
};
ReactionDetailsMainProperties.defaultProps = {
reaction: {},
- onInputChange: () => {}
+ onInputChange: () => {},
+ leadingField: null,
+ leadingFieldColSize: 9,
+ temperatureColSize: 3,
+ showSchemeFields: false,
+ phField: null,
+ vesselSizeField: null,
+ durationField: null,
+ reactionVolumeField: null,
};
diff --git a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsShare.js b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsShare.js
index 4a94688db7..7ca2d79578 100644
--- a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsShare.js
+++ b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionDetailsShare.js
@@ -11,6 +11,14 @@ const setReactionByType = (reaction, type, value) => {
case 'status':
reaction.status = value;
break;
+ case 'reactionType':
+ reaction.reaction_type = value;
+ if (value === 'interaction') {
+ reaction.gaseous = false;
+ reaction.weight_percentage = false;
+ }
+ options = { updateGraphic: true };
+ break;
case 'description':
reaction.description = value;
break;
@@ -51,6 +59,12 @@ const setReactionByType = (reaction, type, value) => {
reaction.conditions = value;
options = {updateGraphic: true}
break;
+ case 'phOperator':
+ reaction.ph_operator = value;
+ break;
+ case 'phValue':
+ reaction.ph_value = value;
+ break;
case 'solvent':
reaction.solvent = value;
options = {updateGraphic: true}
diff --git a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionSchemeGraphic.js b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionSchemeGraphic.js
index 8bf4000cf6..cc34fba5a8 100644
--- a/app/javascript/src/apps/mydb/elements/details/reactions/ReactionSchemeGraphic.js
+++ b/app/javascript/src/apps/mydb/elements/details/reactions/ReactionSchemeGraphic.js
@@ -64,6 +64,7 @@ export default function ReactionSchemeGraphic({
}) {
const [svgProps, setSvgProps] = useState({});
const tooltipContainer = typeof document !== 'undefined' ? document.body : undefined;
+ const isInteractionReaction = reaction.isInteractionReaction();
useEffect(() => {
// Use svgPath for both file URLs and data URIs (raw SVG is encoded as data URI in Reaction.svgPath)
@@ -134,19 +135,23 @@ export default function ReactionSchemeGraphic({
onClick={close}
/>
-
- Starting Materials
- {reaction.starting_materials.map(
- (material) => materialShowLabel(material, false, 'starting_materials')
- )}
-
-
- Reactants
- {reaction.reactants.map((material) => materialShowLabel(material, false, 'reactants'))}
- {reaction.reactant_sbmm_samples.map(
- (material) => materialShowLabel(material, true, 'sbmm_reactants')
- )}
-
+ {!isInteractionReaction && (
+ <>
+
+ Starting Materials
+ {reaction.starting_materials.map(
+ (material) => materialShowLabel(material, false, 'starting_materials')
+ )}
+
+
+ Reactants
+ {reaction.reactants.map((material) => materialShowLabel(material, false, 'reactants'))}
+ {reaction.reactant_sbmm_samples.map(
+ (material) => materialShowLabel(material, true, 'sbmm_reactants')
+ )}
+
+ >
+ )}
Products
{reaction.products.map((material) => materialShowLabel(material, false, 'products'))}
@@ -175,6 +180,7 @@ export default function ReactionSchemeGraphic({
+ Type (Name Reaction Ontology)
+ onInputChange('rxno', event.trim())}
+ selectedDisable={!permitOn(reaction) || reaction.isMethodDisabled('rxno')}
+ />
+
+ );
const solventsItems = solventsTL.map((x, i) => {
const val = Object.keys(x)[0];
return (
@@ -66,17 +77,11 @@ export default class ReactionDetailsProperties extends Component {
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)}
/>
-
+
@@ -72,7 +126,7 @@ export default class ReactionDetailsDuration extends Component {
value={reaction.timestamp_stop || ''}
disabled={!permitOn(reaction) || reaction.isMethodDisabled('timestamp_stop') || reaction.gaseous}
placeholder={timePlaceholder}
- onChange={event => this.props.onInputChange('timestampStop', event)}
+ onChange={event => onInputChange('timestampStop', event)}
/>
+ ) : null}
+ reactionVolumeField={this.reactionVolume()}
/>
-
-
-
-
- Type (Name Reaction Ontology)
- onInputChange('rxno', event.trim())}
- selectedDisable={!permitOn(reaction) || reaction.isMethodDisabled('rxno')}
- />
-
-
-
- {this.renderRole()}
-
-
- {this.reactionVesselSize()}
-
-
- {this.reactionVolume()}
-
-
+ {!isInteractionReaction && (
+
+ )}
+ {/* Interaction mode intentionally drops ontology and role fields from the scheme tab. */}
+ {!isInteractionReaction && (
+
+
+
+ Type (Name Reaction Ontology)
+ onInputChange('rxno', event.trim())}
+ selectedDisable={!permitOn(reaction) || reaction.isMethodDisabled('rxno')}
+ />
+
+
+
+ {this.renderRole()}
+
+
+ )}
Description
@@ -2237,6 +2292,7 @@ export default class ReactionDetailsScheme extends React.Component {
onInputChange={onInputChange}
additionQuillRef={this.additionQuillRef}
onChange={(event) => this.handleMaterialsChange(event)}
+ isInteractionReaction={isInteractionReaction}
/>
>
);
diff --git a/app/javascript/src/fetchers/ReactionSvgFetcher.js b/app/javascript/src/fetchers/ReactionSvgFetcher.js
index 3ec80ce778..ed98b763ca 100644
--- a/app/javascript/src/fetchers/ReactionSvgFetcher.js
+++ b/app/javascript/src/fetchers/ReactionSvgFetcher.js
@@ -2,7 +2,15 @@ import 'whatwg-fetch';
export default class ReactionSvgFetcher {
- static fetchByMaterialsSvgPaths(materialsSvgPaths, temperature, solvents, duration, conditions) {
+ static fetchByMaterialsSvgPaths(
+ materialsSvgPaths,
+ temperature,
+ solvents,
+ duration,
+ conditions,
+ productsOnly = false,
+ showYield = true
+ ) {
const promise = fetch('/api/v1/reaction_svg', {
credentials: 'same-origin',
method: 'post',
@@ -16,6 +24,8 @@ export default class ReactionSvgFetcher {
duration,
solvents,
conditions: (typeof conditions === 'string') ? conditions : '',
+ products_only: productsOnly,
+ show_yield: showYield,
})
}).then((response) => {
return response.status == 201 ? response.json() : {}
diff --git a/app/javascript/src/models/Reaction.js b/app/javascript/src/models/Reaction.js
index 4c547c3168..0c77e15013 100644
--- a/app/javascript/src/models/Reaction.js
+++ b/app/javascript/src/models/Reaction.js
@@ -78,6 +78,11 @@ const DurationDefault = {
memUnit: 'Hour(s)'
};
+const ReactionTypeOptions = [
+ { value: 'standard', label: 'Standard' },
+ { value: 'interaction', label: 'Interaction' },
+];
+
export const convertDuration = (value, unit, newUnit) => moment.duration(Number.parseFloat(value), LegMomentUnit[unit])
.as(MomentUnit[newUnit]);
@@ -128,6 +133,8 @@ export default class Reaction extends Element {
container: Container.init(),
dangerous_products: '',
conditions: '',
+ ph_operator: '=',
+ ph_value: '',
description: Reaction.quillDefault(),
duration: '',
durationDisplay: DurationDefault,
@@ -159,6 +166,7 @@ export default class Reaction extends Element {
vessel_size: { amount: null, unit: 'ml' },
volume: null,
use_reaction_volume: false,
+ reaction_type: 'standard',
gaseous: false,
weight_percentage: false
});
@@ -180,6 +188,10 @@ export default class Reaction extends Element {
return TemperatureUnit;
}
+ static get reaction_type_options() {
+ return ReactionTypeOptions;
+ }
+
get name() {
return this._name;
}
@@ -188,6 +200,10 @@ export default class Reaction extends Element {
this._name = name;
}
+ isInteractionReaction() {
+ return this.reaction_type === 'interaction';
+ }
+
serialize() {
return super.serialize({
collection_id: this.collection_id,
@@ -195,6 +211,8 @@ export default class Reaction extends Element {
description: this.description,
dangerous_products: this.dangerous_products,
conditions: this.conditions,
+ ph_operator: this.ph_operator || '=',
+ ph_value: this.ph_value || '',
duration: this.duration,
durationDisplay: this.durationDisplay,
durationCalc: this.durationCalc(),
@@ -231,6 +249,7 @@ export default class Reaction extends Element {
vessel_size: this.vessel_size,
volume: this.volume,
use_reaction_volume: this.use_reaction_volume,
+ reaction_type: this.reaction_type || 'standard',
gaseous: this.gaseous,
weight_percentage: this.weight_percentage,
});
diff --git a/app/models/reaction.rb b/app/models/reaction.rb
index 04ec5b7e02..8bed95b509 100644
--- a/app/models/reaction.rb
+++ b/app/models/reaction.rb
@@ -19,6 +19,7 @@
# plain_text_observation :text
# purification :string default([]), is an Array
# reaction_svg_file :string
+# reaction_type :string default("standard"), not null
# rf_value :string
# rinchi_long_key :text
# rinchi_short_key :string
@@ -53,6 +54,11 @@
# rubocop:disable Metrics/ClassLength
class Reaction < ApplicationRecord
+ enum reaction_type: {
+ standard: 'standard',
+ interaction: 'interaction',
+ }
+
has_logidze
acts_as_paranoid
include ElementUIStateScopes
@@ -162,6 +168,7 @@ class Reaction < ApplicationRecord
belongs_to :creator, foreign_key: :created_by, class_name: 'User'
+ before_validation :set_default_reaction_type
before_save :update_svg_file!
before_save :cleanup_array_fields
before_save :scrub
@@ -174,6 +181,8 @@ class Reaction < ApplicationRecord
has_one :container, as: :containable
+ validates :reaction_type, inclusion: { in: Reaction.reaction_types.keys }
+
def self.get_associated_samples(reaction_ids)
ReactionsSample.where(reaction_id: reaction_ids).pluck(:sample_id)
end
@@ -249,11 +258,15 @@ def update_svg_file!
# SBMM reactants are stored in a separate association, so append them explicitly.
paths[:reactants] += reactant_sbmm_samples.map { |sbmm_sample| [sbmm_sample.svg_text_path] }
begin
- composer = SVG::ReactionComposer.new(paths, temperature: temperature_display_with_unit,
- duration: duration,
- solvents: solvents_in_svg,
- conditions: conditions,
- show_yield: true)
+ composer_options = {
+ temperature: temperature_display_with_unit,
+ duration: duration,
+ solvents: solvents_in_svg,
+ conditions: conditions,
+ show_yield: !interaction?,
+ }
+ composer_class = interaction? ? SVG::ProductsComposer : SVG::ReactionComposer
+ composer = composer_class.new(paths, composer_options)
self.reaction_svg_file = composer.compose_reaction_svg_and_save
rescue StandardError => _e
Rails.logger.info('**** SVG::ReactionComposer failed ***')
@@ -326,6 +339,10 @@ def assign_attachment_to_variation(variation_id, analysis_id)
private
+ def set_default_reaction_type
+ self.reaction_type = 'standard' if reaction_type.blank?
+ end
+
def scrubber(value)
Chemotion::Sanitizer.scrub_xml(value)
end
diff --git a/db/migrate/20260316120000_add_reaction_type_to_reactions.rb b/db/migrate/20260316120000_add_reaction_type_to_reactions.rb
new file mode 100644
index 0000000000..a81ba64a07
--- /dev/null
+++ b/db/migrate/20260316120000_add_reaction_type_to_reactions.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddReactionTypeToReactions < ActiveRecord::Migration[6.1]
+ def change
+ add_column :reactions, :reaction_type, :string, default: 'standard', null: false
+ end
+end
diff --git a/db/migrate/20260316133000_add_ph_fields_to_reactions.rb b/db/migrate/20260316133000_add_ph_fields_to_reactions.rb
new file mode 100644
index 0000000000..a28ba0c002
--- /dev/null
+++ b/db/migrate/20260316133000_add_ph_fields_to_reactions.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddPhFieldsToReactions < ActiveRecord::Migration[6.1]
+ def change
+ add_column :reactions, :ph_operator, :string, default: '=', null: false
+ add_column :reactions, :ph_value, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 083b3aefb9..45403c914f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2026_02_20_000002) do
+ActiveRecord::Schema.define(version: 2026_03_16_133000) do
# These are extensions that must be enabled in order to support this database
enable_extension "hstore"
@@ -1045,6 +1045,9 @@
t.decimal "volume", precision: 10, scale: 4
t.boolean "use_reaction_volume", default: false, null: false
t.boolean "weight_percentage"
+ t.string "reaction_type", default: "standard", null: false
+ t.string "ph_operator", default: "=", null: false
+ t.string "ph_value"
t.index ["deleted_at"], name: "index_reactions_on_deleted_at"
t.index ["rinchi_short_key"], name: "index_reactions_on_rinchi_short_key", order: :desc
t.index ["rinchi_web_key"], name: "index_reactions_on_rinchi_web_key"
diff --git a/lib/svg/reaction_composer.rb b/lib/svg/reaction_composer.rb
index d676977418..18bc9d9988 100644
--- a/lib/svg/reaction_composer.rb
+++ b/lib/svg/reaction_composer.rb
@@ -215,6 +215,10 @@ def compose_reaction_svg
"#{template_it.strip} #{sections_string_filtered} "
end
+ def compose_svg
+ compose_reaction_svg
+ end
+
private
def init_materials(materials_svg_paths)
diff --git a/spec/models/reaction_spec.rb b/spec/models/reaction_spec.rb
index 6a06923352..e479814db0 100644
--- a/spec/models/reaction_spec.rb
+++ b/spec/models/reaction_spec.rb
@@ -19,6 +19,7 @@
# plain_text_observation :text
# purification :string default([]), is an Array
# reaction_svg_file :string
+# reaction_type :string default("standard"), not null
# rf_value :string
# rinchi_long_key :text
# rinchi_short_key :string