diff --git a/app/api/api.rb b/app/api/api.rb index 2bbf016ce8..2e07de457b 100644 --- a/app/api/api.rb +++ b/app/api/api.rb @@ -206,6 +206,7 @@ def to_json_camel_case(val) mount Chemotion::InventoryAPI mount Chemotion::AdminDeviceAPI mount Chemotion::AdminDeviceMetadataAPI + mount Chemotion::ComponentAPI if Rails.env.development? add_swagger_documentation(info: { diff --git a/app/api/chemotion/component_api.rb b/app/api/chemotion/component_api.rb new file mode 100644 index 0000000000..b0e03d835e --- /dev/null +++ b/app/api/chemotion/component_api.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Chemotion + class ComponentAPI < Grape::API + resource :components do + desc 'Return components by sample_id' + params do + requires :sample_id, type: Integer, desc: 'sample id' + end + + route_param :sample_id do + get do + components = Component.where(sample_id: params[:sample_id]) + components_with_molecule_data = components.map do |component| + molecule_id = component.component_properties['molecule_id'] + molecule = Molecule.find_by(id: molecule_id) + component.component_properties['molecule'] = molecule + component + end + present components_with_molecule_data + end + end + + desc 'Save or update components for a given sample' + params do + requires :sample_id, type: Integer, desc: 'sample id' + requires :components, type: Array, desc: 'components' do + requires :id, types: [Integer, String], desc: 'Component ID' + optional :name, type: String, desc: 'Component name' + optional :position, type: Integer, desc: 'Component position in the table' + requires :component_properties, type: Hash, desc: 'Component properties' do + optional :amount_mol, type: Float, desc: 'Component moles' + optional :amount_l, type: Float, desc: 'Component volume' + optional :amount_g, type: Float, desc: 'Component mass' + optional :density, type: Float, desc: 'Density in g/ml' + optional :molarity_unit, type: String, desc: 'Molarity unit' + optional :molarity_value, type: Float, desc: 'Molarity value' + optional :starting_molarity_value, type: Float, desc: 'Starting molarity value' + optional :starting_molarity_unit, type: String, desc: 'Starting molarity unit' + requires :molecule_id, type: Integer, desc: 'Molecule ID' + optional :equivalent, types: [Float, String], desc: 'Equivalent' + optional :parent_id, type: Integer, desc: 'Parent ID' + optional :material_group, type: String, desc: 'type of component e.g. liquid' + optional :reference, type: Boolean, desc: 'reference comp. for ratio calculations' + optional :purity, type: Float, desc: 'Component purity' + end + end + end + + put do + sample_id = params[:sample_id] + components_params = params[:components] + + components_params.each do |component_params| + molecule_id = component_params[:component_properties][:molecule_id] + + component = Component.where("sample_id = ? AND CAST(component_properties ->> 'molecule_id' AS INTEGER) = ?", + sample_id, molecule_id) + .first_or_initialize(sample_id: sample_id) + + component.update( + name: component_params[:name], + position: component_params[:position], + component_properties: component_params[:component_properties], + ) + end + # Delete components + molecule_ids_to_keep = components_params.map { |cp| cp[:component_properties][:molecule_id] }.compact + Component.where(sample_id: sample_id) + .where.not("CAST(component_properties ->> 'molecule_id' AS INTEGER) IN (?)", molecule_ids_to_keep) + &.destroy_all + end + end + end +end diff --git a/app/api/chemotion/sample_api.rb b/app/api/chemotion/sample_api.rb index 2ab411638e..496594b8b8 100644 --- a/app/api/chemotion/sample_api.rb +++ b/app/api/chemotion/sample_api.rb @@ -347,6 +347,8 @@ class SampleAPI < Grape::API optional :molecular_mass, type: Float optional :sum_formula, type: String # use :root_container_params + optional :sample_type, type: String, default: 'Micromolecule' + optional :sample_details, type: Hash, desc: 'extra params for mixtures or polymers' end route_param :id do @@ -393,7 +395,14 @@ class SampleAPI < Grape::API attributes.delete(:melting_point_lowerbound) attributes.delete(:melting_point_upperbound) + micro_att = { + name: params[:name], + molfile: params[:molfile], + stereo: params[:stereo], + } + @sample.update!(attributes) + @sample.micromolecule&.update(micro_att) @sample.save_segments(segments: params[:segments], current_user_id: current_user.id) # save to profile @@ -453,6 +462,8 @@ class SampleAPI < Grape::API optional :inventory_sample, type: Boolean, default: false optional :molecular_mass, type: Float optional :sum_formula, type: String + optional :sample_type, type: String, default: 'Micromolecule' + optional :sample_details, type: Hash, desc: 'extra params for mixtures or polymers' end post do molecule_id = if params[:decoupled] && params[:molfile].blank? @@ -475,7 +486,6 @@ class SampleAPI < Grape::API dry_solvent: params[:dry_solvent], solvent: params[:solvent], location: params[:location], - molfile: params[:molfile], molecule_id: molecule_id, sample_svg_file: params[:sample_svg_file], is_top_secret: params[:is_top_secret], @@ -484,12 +494,20 @@ class SampleAPI < Grape::API elemental_compositions: params[:elemental_compositions], created_by: current_user.id, xref: params[:xref], - stereo: params[:stereo], molecule_name_id: params[:molecule_name_id], decoupled: params[:decoupled], inventory_sample: params[:inventory_sample], molecular_mass: params[:molecular_mass], sum_formula: params[:sum_formula], + sample_type: params[:sample_type], + molfile: params[:molfile], + stereo: params[:stereo], + sample_details: params[:sample_details], + } + micro_att = { + name: params[:name], + molfile: params[:molfile], + stereo: params[:stereo], } boiling_point_lowerbound = (params['boiling_point_lowerbound'].presence || -Float::INFINITY) @@ -543,9 +561,15 @@ class SampleAPI < Grape::API sample.collections << all_coll end + case params[:sample_type] + when 'Micromolecule' + micromolecule = Micromolecule.new(micro_att) + micromolecule.samples << sample + micromolecule.save! + end + sample.container = update_datamodel(params[:container]) sample.save! - sample.save_segments(segments: params[:segments], current_user_id: current_user.id) # save to profile @@ -566,7 +590,9 @@ class SampleAPI < Grape::API delete do sample = Sample.find(params[:id]) + micromolecule = Micromolecule.find_by(id: sample.micromolecule_id) sample.destroy + micromolecule&.destroy end end end diff --git a/app/api/entities/sample_entity.rb b/app/api/entities/sample_entity.rb index 1e07bb0b5d..0f1c727a33 100644 --- a/app/api/entities/sample_entity.rb +++ b/app/api/entities/sample_entity.rb @@ -71,6 +71,8 @@ class SampleEntity < ApplicationEntity expose! :target_amount_value, unless: :displayed_in_list expose! :user_labels expose! :xref + expose! :sample_type + expose! :sample_details end # rubocop:enable Layout/LineLength, Layout/ExtraSpacing, Metrics/BlockLength diff --git a/app/models/component.rb b/app/models/component.rb new file mode 100644 index 0000000000..fa1145edee --- /dev/null +++ b/app/models/component.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Component < ApplicationRecord + belongs_to :sample +end diff --git a/app/models/micromolecule.rb b/app/models/micromolecule.rb new file mode 100644 index 0000000000..fe7b1b2f84 --- /dev/null +++ b/app/models/micromolecule.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Micromolecule < ApplicationRecord + has_many :samples +end diff --git a/app/models/sample.rb b/app/models/sample.rb index a0b71ced1d..42a1873e38 100644 --- a/app/models/sample.rb +++ b/app/models/sample.rb @@ -160,7 +160,6 @@ class Sample < ApplicationRecord Sample.where(id: samples.map(&:id)) } - before_save :auto_set_molfile_to_molecules_molfile before_save :find_or_create_molecule_based_on_inchikey before_save :update_molecule_name @@ -201,6 +200,9 @@ class Sample < ApplicationRecord has_many :private_notes, as: :noteable, dependent: :destroy has_many :comments, as: :commentable, dependent: :destroy + belongs_to :micromolecule, optional: true + has_many :components, dependent: :destroy + belongs_to :fingerprint, optional: true belongs_to :user, optional: true belongs_to :molecule_name, optional: true @@ -233,6 +235,7 @@ class Sample < ApplicationRecord delegate :computed_props, to: :molecule, prefix: true delegate :inchikey, to: :molecule, prefix: true, allow_nil: true + delegate :molfile, :molfile_version, :stereo, to: :micromolecule, prefix: true, allow_nil: true attr_writer :skip_reaction_svg_update @@ -583,7 +586,7 @@ def set_elem_composition_data d_type, d_values, loading = nil end def check_molfile_polymer_section - return if decoupled + return if decoupled || sample_type == 'Mixture' return unless self.molfile.include? 'R#' lines = self.molfile.lines diff --git a/app/packs/src/apps/mydb/elements/details/NumeralInputWithUnitsCompo.js b/app/packs/src/apps/mydb/elements/details/NumeralInputWithUnitsCompo.js index a147a5998c..317ff8fd12 100644 --- a/app/packs/src/apps/mydb/elements/details/NumeralInputWithUnitsCompo.js +++ b/app/packs/src/apps/mydb/elements/details/NumeralInputWithUnitsCompo.js @@ -1,6 +1,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import { FormControl, ControlLabel, InputGroup, Button } from 'react-bootstrap'; +import { + FormControl, ControlLabel, InputGroup, Button, OverlayTrigger, Tooltip +} from 'react-bootstrap'; import { metPreConv, metPrefSymbols } from 'src/utilities/metricPrefix'; export default class NumeralInputWithUnitsCompo extends Component { @@ -126,7 +128,7 @@ export default class NumeralInputWithUnitsCompo extends Component { render() { const { - bsSize, bsStyle, disabled, label, unit, name + bsSize, bsStyle, disabled, label, unit, name, showInfoTooltipTotalVol } = this.props; const { showString, value, metricPrefix, @@ -164,6 +166,20 @@ export default class NumeralInputWithUnitsCompo extends Component { return (
{labelWrap} + {showInfoTooltipTotalVol && ( + + It is only a value given manually, i.e. volume by definition - not (re)calculated + + )} + > + + + + + )} - + { + const sampleComponents = components.map(component => { + const { component_properties, ...rest } = component; + const sampleData = { + ...rest, + ...component_properties + }; + return new Component(sampleData); + }); + await splitSample.initialComponents(sampleComponents); + const comp = sampleComponents.find(component => component.amount_mol > 0 && component.molarity_value > 0); + if (comp) { + splitSample.target_amount_value = comp.amount_mol / comp.molarity_value; + splitSample.target_amount_unit = 'l'; + } + reaction.addMaterialAt(splitSample, null, tagMaterial, tagGroup); + this.onReactionChange(reaction, { schemaChanged: true }); + }) + .catch((errorMessage) => { + console.log(errorMessage); + }); + } else { + this.insertSolventExtLabel(splitSample, tagGroup, extLabel); + reaction.addMaterialAt(splitSample, null, tagMaterial, tagGroup); + this.onReactionChange(reaction, { schemaChanged: true }); + } } insertSolventExtLabel(splitSample, materialGroup, external_label) { diff --git a/app/packs/src/apps/mydb/elements/details/samples/SampleDetails.js b/app/packs/src/apps/mydb/elements/details/samples/SampleDetails.js index 54f9591292..88a7e16701 100644 --- a/app/packs/src/apps/mydb/elements/details/samples/SampleDetails.js +++ b/app/packs/src/apps/mydb/elements/details/samples/SampleDetails.js @@ -80,7 +80,7 @@ import { commentActivation } from 'src/utilities/CommentHelper'; const MWPrecision = 6; const decoupleCheck = (sample) => { - if (!sample.decoupled && sample.molecule && sample.molecule.id === '_none_') { + if (!sample.decoupled && sample.molecule && sample.molecule.id === '_none_' && !sample.sample_type == 'Mixture') { NotificationActions.add({ title: 'Error on Sample creation', message: 'The molecule structure is required!', level: 'error', position: 'tc' }); @@ -164,6 +164,7 @@ export default class SampleDetails extends React.Component { this.handleStructureEditorSave = this.handleStructureEditorSave.bind(this); this.handleStructureEditorCancel = this.handleStructureEditorCancel.bind(this); + this.splitSmiles = this.splitSmiles.bind(this); } componentDidMount() { @@ -325,6 +326,7 @@ export default class SampleDetails extends React.Component { const fetchMolecule = (fetchFunction) => { fetchFunction() .then(fetchSuccess).catch(fetchError).finally(() => { + this.splitSmiles(editor, svgFile) this.hideStructureEditor(); }); }; @@ -336,7 +338,7 @@ export default class SampleDetails extends React.Component { } else { fetchMolecule(() => MoleculesFetcher.fetchBySmi(smiles, svgFile, molfile, editor)); } - } + } handleStructureEditorCancel() { this.hideStructureEditor(); @@ -477,6 +479,7 @@ export default class SampleDetails extends React.Component { molfile={molfile} hasParent={hasParent} hasChildren={hasChildren} + sample={sample} /> ); } @@ -685,7 +688,9 @@ export default class SampleDetails extends React.Component { saveBtn(sample, closeView = false) { let submitLabel = (sample && sample.isNew) ? 'Create' : 'Save'; - const isDisabled = !sample.can_update; + const hasComponents = sample.sample_type !== 'Mixture' + || (sample.components && sample.components.length > 0); + const isDisabled = !sample.can_update || !hasComponents; if (closeView) submitLabel += ' and close'; return ( @@ -755,7 +760,7 @@ export default class SampleDetails extends React.Component { elementalPropertiesItem(sample) { // avoid empty ListGroupItem - if (!sample.molecule_formula) { + if (!sample.molecule_formula || sample.sample_type === 'Mixture') { return false; } @@ -964,7 +969,9 @@ export default class SampleDetails extends React.Component { const timesTag = ( ); - const sampleUpdateCondition = !this.sampleIsValid() || !sample.can_update; + const hasComponents = sample.sample_type !== 'Mixture' + || (sample.components && sample.components.length > 0); + const sampleUpdateCondition = !this.sampleIsValid() || !sample.can_update || !hasComponents; const elementToSave = activeTab === 'inventory' ? 'Chemical' : 'Sample'; const saveAndClose = ( @@ -1038,6 +1045,8 @@ export default class SampleDetails extends React.Component { ) : null; + const isMixture = sample.sample_type === 'Mixture'; + return (
@@ -1055,7 +1064,7 @@ export default class SampleDetails extends React.Component { - {sample.isNew + {sample.isNew && !isMixture ? : null}
@@ -1105,6 +1114,7 @@ export default class SampleDetails extends React.Component { } sampleInfo(sample) { + const isMixture = sample.sample_type === 'Mixture'; const style = { height: 'auto', marginBottom: '20px' }; let pubchemLcss = (sample.pubchem_tag && sample.pubchem_tag.pubchem_lcss && sample.pubchem_tag.pubchem_lcss.Record) || null; @@ -1128,7 +1138,7 @@ export default class SampleDetails extends React.Component {

{this.sampleAverageMW(sample)}
{this.sampleExactMW(sample)}
- {sample.isNew ? null :
{this.moleculeCas()}
} + {sample.isNew || isMixture ? null :
{this.moleculeCas()}
} {lcssSign} @@ -1411,12 +1421,20 @@ export default class SampleDetails extends React.Component { } sampleAverageMW(sample) { - const mw = sample.molecule_molecular_weight; + let mw; + + if (sample.sample_type === 'Mixture' && sample.sample_details) { + mw = sample.total_molecular_weight; + } else { + mw = sample.molecule_molecular_weight; + } + if (mw) return ; return ''; } sampleExactMW(sample) { + if (sample.sample_type === 'Mixture' && sample.sample_details) { return } const mw = sample.molecule_exact_molecular_weight; if (mw) return ; return ''; @@ -1445,6 +1463,19 @@ export default class SampleDetails extends React.Component { }); } + splitSmiles(editor, svgFile) { + const { sample } = this.state; + if (sample.sample_type !== 'Mixture' || !sample.molecule_cano_smiles || sample.molecule_cano_smiles === '') { return } + + const mixtureSmiles = sample.molecule_cano_smiles.split('.') + if (mixtureSmiles) { + sample.splitSmilesToMolecule(mixtureSmiles, editor) + .then(() => { + this.setState({ sample }); + }); + } + } + toggleInchi() { const { showInchikey } = this.state; this.setState({ showInchikey: !showInchikey }); @@ -1613,6 +1644,8 @@ export default class SampleDetails extends React.Component { const activeTab = (this.state.activeTab !== 0 && stb.indexOf(this.state.activeTab) > -1 && this.state.activeTab) || visible.get(0); + const isMixture = sample.sample_type === 'Mixture'; + return ( ({ + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging(), +}); + +const matTagCollect = (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), +}); + +class SampleComponent extends Component { + constructor(props) { + super(props); + + this.handleAmountChange = this.handleAmountChange.bind(this); + this.handleMetricsChange = this.handleMetricsChange.bind(this); + this.handleDensityChange = this.handleDensityChange.bind(this); + this.handlePurityChange = this.handlePurityChange.bind(this); + this.handleNameChange = this.handleNameChange.bind(this); + this.handleRatioChange = this.handleRatioChange.bind(this); + this.handleReferenceChange = this.handleReferenceChange.bind(this); + } + + handleAmountChange(e, value, concType, lockColumn) { + if (e.value === value) return; + const { materialGroup } = this.props; + + if (this.props.onChange && e) { + const event = { + amount: e, + type: 'amountChanged', + materialGroup, + sampleID: this.componentId(), + concType, + lockColumn, + }; + this.props.onChange(event); + } + } + + handleDensityChange(e, value, lockColumn) { + if (e.value === value) return; + + if (this.props.onChange && e) { + const event = { + amount: e, + type: 'densityChanged', + materialGroup: this.props.materialGroup, + sampleID: this.componentId(), + lockColumn, + }; + this.props.onChange(event); + } + } + + handlePurityChange(e, value) { + if (e.value === value) return; + + if (this.props.onChange && e) { + const event = { + amount: e, + type: 'purityChanged', + materialGroup: this.props.materialGroup, + sampleID: this.componentId(), + }; + this.props.onChange(event); + } + } + + handleMetricsChange(e) { + if (this.props.onChange && e) { + const event = { + metricUnit: e.metricUnit, + metricPrefix: e.metricPrefix, + type: 'MetricsChanged', + materialGroup: this.props.materialGroup, + sampleID: this.componentId(), + + }; + this.props.onChange(event); + } + } + + componentId() { + return this.component().id; + } + + component() { + return this.props.material; + } + + materialNameWithIupac(material) { + let moleculeIupacName = ''; + const iupacStyle = { + display: 'block', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + maxWidth: '100%' + }; + + moleculeIupacName = material.molecule_iupac_name; + + if (moleculeIupacName === '' || !moleculeIupacName) { + moleculeIupacName = material.molecule.sum_formular; + } + + return ( +
+ + {this.svgPreview(material, moleculeIupacName)} + +
+ ); + } + + nameInput(material) { + return ( + + { this.handleNameChange(e, material.name); }} + disabled={!permitOn(this.props.sample)} + /> + + ); + } + + handleNameChange(e, value) { + if (e.value === value) return; + + if (this.props.onChange && e) { + const event = { + newName: e.target.value, + type: 'nameChanged', + sampleID: this.componentId(), + + }; + this.props.onChange(event); + } + } + + handleRatioChange(e, value) { + if (e.value === value) return; + + const { materialGroup } = this.props; + + if (this.props.onChange && e) { + const event = { + newRatio: e.value, + type: 'ratioChanged', + sampleID: this.componentId(), + materialGroup, + }; + this.props.onChange(event); + } + } + + handleReferenceChange(e) { + const { value } = e.target; + if (this.props.onChange) { + const event = { + type: 'referenceChanged', + materialGroup: this.props.materialGroup, + sampleID: this.componentId(), + value + }; + this.props.onChange(event); + } + } + + generateMolecularWeightTooltipText(sample) { + const molecularWeight = sample.decoupled + ? (sample.molecular_mass) : (sample.molecule && sample.molecule.molecular_weight); + return `molar mass: ${molecularWeight} g/mol`; + } + + materialVolume(material) { + if (material.contains_residues) { return notApplicableInput(); } + + const { + sample, enableComponentLabel, enableComponentPurity + } = this.props; + const metricPrefixes = ['m', 'n', 'u']; + const metric = (material.metrics && material.metrics.length > 2 && metricPrefixes.indexOf(material.metrics[1]) > -1) ? material.metrics[1] : 'm'; + + return ( + + this.handleAmountChange(e, material.amount_l)} + onMetricsChange={this.handleMetricsChange} + bsStyle={material.amount_unit === 'l' ? 'success' : 'default'} + /> + + ); + } + + componentMass(material, metric, metricPrefixes, massBsStyle) { + return ( + {this.generateMolecularWeightTooltipText(material)} + } + > +
+ this.handleAmountChange(e, material.amount_g)} + onMetricsChange={this.handleMetricsChange} + bsStyle={material.error_mass ? 'error' : massBsStyle} + name="molecular-weight" + /> +
+
+ ); + } + + componentMol(material, metricMol, metricPrefixesMol) { + const { + sample, enableComponentLabel, enableComponentPurity + } = this.props; + + return ( + + this.handleAmountChange(e, material.amount_mol)} + onMetricsChange={this.handleMetricsChange} + bsStyle={material.amount_unit === 'mol' ? 'success' : 'default'} + /> + + ); + } + + componentConc(material, metricMolConc, metricPrefixesMolConc) { + const { sample } = this.props; + return ( + + this.handleAmountChange(e, material.concn, 'targetConc')} + onMetricsChange={this.handleMetricsChange} + /> + + ); + } + + componentStartingConc(material, metricMolConc, metricPrefixesMolConc) { + const { sample, lockAmountColumn } = this.props; + + return ( + + this.handleAmountChange(e, material.startingConc, 'startingConc', lockAmountColumn)} + onMetricsChange={this.handleMetricsChange} + /> + + ); + } + + materialRef(material) { + return ( + + this.handleReferenceChange(e)} + bsSize="xsmall" + style={{ margin: 0 }} + /> + + ); + } + + componentDensity(material) { + const { + sample, materialGroup, lockAmountColumn, lockAmountColumnSolids + } = this.props; + const lockColumn = materialGroup === 'liquid' ? lockAmountColumn : lockAmountColumnSolids; + + return ( + + this.handleDensityChange(e, material.density, lockColumn)} + /> + + ); + } + + mixtureComponent(props, style) { + const { + sample, material, deleteMaterial, connectDragSource, connectDropTarget, activeTab, + enableComponentLabel, enableComponentPurity + } = props; + const metricPrefixes = ['m', 'n', 'u']; + const metricPrefixesMol = ['m', 'n']; + const metricMol = (material.metrics && material.metrics.length > 2 && metricPrefixes.indexOf(material.metrics[2]) > -1) ? material.metrics[2] : 'm'; + const metricPrefixesMolConc = ['m', 'n']; + const metricMolConc = (material.metrics && material.metrics.length > 3 && metricPrefixes.indexOf(material.metrics[3]) > -1) ? material.metrics[3] : 'm'; + + return ( + + {compose(connectDragSource, connectDropTarget)( + + + , + { dropEffect: 'copy' } + )} + + + {this.materialNameWithIupac(material)} + + + + + + + {activeTab === 'concentration' && this.componentStartingConc(material, metricMolConc, metricPrefixesMolConc)} + {activeTab === 'density' && this.componentDensity(material)} + + {this.materialVolume(material)} + + {this.componentMol(material, metricMol, metricPrefixesMol)} + + + this.handleRatioChange(e, material.equivalent)} + /> + + + {this.materialRef(material)} + + {this.componentConc(material, metricMolConc, metricPrefixesMolConc)} + + { + enableComponentLabel && ( + + {this.nameInput(material)} + + ) + } + + { + enableComponentPurity && ( + + this.handlePurityChange(e, material.purity)} + /> + + ) + } + + ); + } + + solidComponent(props, style) { + const { + sample, material, deleteMaterial, connectDragSource, connectDropTarget + } = props; + const metricPrefixes = ['m', 'n', 'u']; + const metric = (material.metrics && material.metrics.length > 2 && metricPrefixes.indexOf(material.metrics[0]) > -1) ? material.metrics[0] : 'm'; + const metricPrefixesMol = ['m', 'n']; + const metricMol = (material.metrics && material.metrics.length > 2 && metricPrefixes.indexOf(material.metrics[2]) > -1) ? material.metrics[2] : 'm'; + const massBsStyle = material.amount_unit === 'g' ? 'success' : 'default'; + const metricPrefixesMolConc = ['m', 'n']; + const metricMolConc = (material.metrics && material.metrics.length > 3 && metricPrefixes.indexOf(material.metrics[3]) > -1) ? material.metrics[3] : 'm'; + + return ( + + {compose(connectDragSource, connectDropTarget)( + + + , + { dropEffect: 'copy' } + )} + + + {this.materialNameWithIupac(material)} + + + {this.materialRef(material)} + + + {this.componentMass(material, metric, metricPrefixes, massBsStyle)} + + + + {this.componentMol(material, metricMol, metricPrefixesMol)} + + {this.componentDensity(material, metricMol, metricPrefixesMol)} + {this.componentConc(material, metricMolConc, metricPrefixesMolConc)} + + + this.handlePurityChange(e, material.purity)} + /> + + + + this.handleRatioChange(e, material.equivalent)} + /> + + + + + + + ); + } + + svgPreview(material, moleculeIupacName) { + return ( + + ); + } + + render() { + const { + isDragging, canDrop, isOver, material + } = this.props; + const style = { padding: '0' }; + if (isDragging) { style.opacity = 0.3; } + if (canDrop) { + style.borderStyle = 'dashed'; + style.borderWidth = 2; + } + if (isOver) { + style.borderColor = '#337ab7'; + style.opacity = 0.6; + style.backgroundColor = '#337ab7'; + } + + if (material.material_group === 'liquid') { + return this.mixtureComponent(this.props, style); + } + return this.solidComponent(this.props, style); + } +} + +export default compose( + DragSource( + DragDropItemTypes.MATERIAL, + matSource, + matSrcCollect, + ), + DropTarget( + [DragDropItemTypes.SAMPLE, DragDropItemTypes.MOLECULE, DragDropItemTypes.MATERIAL], + matTarget, + matTagCollect, + ), +)(SampleComponent); + +SampleComponent.propTypes = { + sample: PropTypes.object.isRequired, + material: PropTypes.instanceOf(Sample).isRequired, + materialGroup: PropTypes.string.isRequired, + deleteMaterial: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + index: PropTypes.number, + isDragging: PropTypes.bool, + canDrop: PropTypes.bool, + isOver: PropTypes.bool, + lockAmountColumn: PropTypes.bool.isRequired, + lockAmountColumnSolids: PropTypes.bool.isRequired, + enableComponentLabel: PropTypes.bool.isRequired, + enableComponentPurity: PropTypes.bool.isRequired, +}; diff --git a/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleComponentsGroup.js b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleComponentsGroup.js new file mode 100644 index 0000000000..62c97c7563 --- /dev/null +++ b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleComponentsGroup.js @@ -0,0 +1,187 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, OverlayTrigger, Tooltip, Tab, Tabs, ControlLabel +} from 'react-bootstrap'; +import Sample from 'src/models/Sample'; +import Component from 'src/models/Component'; +import SampleComponent from 'src/apps/mydb/elements/details/samples/propertiesTab/SampleComponent'; + +const SampleComponentsGroup = ({ + materialGroup, deleteMixtureComponent, onChange, sample, + headIndex, dropSample, dropMaterial, lockAmountColumn, lockAmountColumnSolids, switchAmount, sampleComponents, + showModalWithMaterial, activeTab, handleTabSelect, enableComponentLabel, enableComponentPurity +}) => { + const contents = []; + if (sampleComponents && sampleComponents.length > 0) { + sampleComponents = sampleComponents.map((component) => { + if (!(component instanceof Component)) { + return new Component(component); + } + return component; + }); + let index = headIndex; + sampleComponents.forEach((sampleComponent) => { + index += 1; + contents.push(( + deleteMixtureComponent(sc, materialGroup)} + index={index} + dropMaterial={dropMaterial} + dropSample={dropSample} + lockAmountColumn={lockAmountColumn} + lockAmountColumnSolids={lockAmountColumnSolids} + showModalWithMaterial={showModalWithMaterial} + activeTab={activeTab} + handleTabSelect={handleTabSelect} + enableComponentLabel={enableComponentLabel} + enableComponentPurity={enableComponentPurity} + /> + )); + }); + } + + const headers = { + name: 'Label', + amount: 'Amount', + mass: 'Mass', + volume: 'Volume', + startingConc: 'Stock', + concn: 'Total Conc.', + eq: 'Ratio', + ref: 'Ref', + purity: 'Purity', + density: 'Density', + }; + + if (materialGroup === 'solid') { + headers.group = 'Solids'; + } else { + headers.group = 'Liquids'; + } + + const switchAmountTooltip = () => ( + + + Lock/unlock amounts + + + (mass/stock/density) + + + ); + + const SwitchAmountButton = (lockAmountColumn, switchAmount, materialGroup) => ( + + + + ); + + return ( +
+ + + + + + + + + + + + {enableComponentLabel && } + {enableComponentPurity && } + + + + + + )} + + {materialGroup === 'liquid' && ( + + )} + + {materialGroup === 'liquid' && ( + + )} + + + {materialGroup === 'solid' && } + + + + {enableComponentLabel && } + {enableComponentPurity && } + + + + {contents.map((item) => item)} + +
+ {headers.group} + {materialGroup === 'solid' && ( + + {SwitchAmountButton(lockAmountColumnSolids, switchAmount, materialGroup)} {headers.mass} + +
+ + + + + {SwitchAmountButton(lockAmountColumn, switchAmount, materialGroup)} +
+
+ {headers.volume} + {headers.amount}{headers.density}{headers.eq}{headers.ref} + {headers.concn} + + Total Conc. will only be calculated when we have a Total volume + + )} + > + + + + + {headers.name}{headers.purity}
+
+ ); +}; + +SampleComponentsGroup.propTypes = { + materialGroup: PropTypes.string.isRequired, + headIndex: PropTypes.number.isRequired, + deleteMixtureComponent: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + sample: PropTypes.instanceOf(Sample).isRequired, + dropSample: PropTypes.func.isRequired, + dropMaterial: PropTypes.func.isRequired, + switchAmount: PropTypes.func.isRequired, + lockAmountColumn: PropTypes.bool.isRequired, + lockAmountColumnSolids: PropTypes.bool.isRequired, + enableComponentLabel: PropTypes.bool.isRequired, + enableComponentPurity: PropTypes.bool.isRequired, +}; + +export default SampleComponentsGroup; diff --git a/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponents.js b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponents.js new file mode 100644 index 0000000000..0fd307f108 --- /dev/null +++ b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponents.js @@ -0,0 +1,397 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Sample from 'src/models/Sample'; +import Molecule from 'src/models/Molecule'; +import SampleDetailsComponentsDnd from 'src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponentsDnd'; // Import the appropriate Dnd component +import UIStore from 'src/stores/alt/stores/UIStore'; +import ComponentsFetcher from 'src/fetchers/ComponentsFetcher'; +import Component from 'src/models/Component'; +import { + ListGroup, ListGroupItem, Button, Modal +} from 'react-bootstrap'; + +export default class SampleDetailsComponents extends React.Component { + constructor(props) { + super(props); + + const { sample } = props; + this.state = { + sample, + showModal: false, + droppedMaterial: null, + activeTab: 'concentration', + lockAmountColumn: true, + lockAmountColumnSolids: false, + }; + + this.dropSample = this.dropSample.bind(this); + this.dropMaterial = this.dropMaterial.bind(this); + this.deleteMixtureComponent = this.deleteMixtureComponent.bind(this); + this.onChangeComponent = this.onChangeComponent.bind(this); + this.updatedSampleForAmountUnitChange = this.updatedSampleForAmountUnitChange.bind(this); + this.updatedSampleForMetricsChange = this.updatedSampleForMetricsChange.bind(this); + this.switchAmount = this.switchAmount.bind(this); + this.updateComponentName = this.updateComponentName.bind(this); + this.updateRatio = this.updateRatio.bind(this); + this.updateSampleForReferenceChanged = this.updateSampleForReferenceChanged.bind(this); + this.showModalWithMaterial = this.showModalWithMaterial.bind(this); + this.handleModalClose = this.handleModalClose.bind(this); + this.handleModalAction = this.handleModalAction.bind(this); + this.handleTabSelect = this.handleTabSelect.bind(this); + this.updatePurity = this.updatePurity.bind(this); + } + + handleModalClose() { + this.setState({ showModal: false, droppedMaterial: null }); + } + + handleModalAction(action) { + const { droppedMaterial, sample } = this.state; + + if (droppedMaterial) { + const { + srcMat, srcGroup, tagMat, tagGroup + } = droppedMaterial; + this.dropMaterial(srcMat, srcGroup, tagMat, tagGroup, action); + } + this.handleModalClose(); + this.props.onChange(sample); + } + + handleTabSelect(tab) { + this.setState({ activeTab: tab }); + } + + onChangeComponent(changeEvent) { + const { sample } = this.state; + + sample.components = sample.components.map((component) => { + if (!(component instanceof Component)) { + return new Component(component); + } + return component; + }); + + switch (changeEvent.type) { + case 'amountChanged': + this.updatedSampleForAmountUnitChange(changeEvent); + break; + case 'MetricsChanged': + this.updatedSampleForMetricsChange(changeEvent); + break; + case 'nameChanged': + this.updateComponentName(changeEvent); + break; + case 'ratioChanged': + this.updateRatio(changeEvent); + break; + case 'referenceChanged': + this.updateSampleForReferenceChanged(changeEvent); + break; + case 'purityChanged': + this.updatePurity(changeEvent); + break; + case 'densityChanged': + this.updateDensity(changeEvent); + break; + default: + break; + } + this.props.onChange(sample); + } + + updatedSampleForAmountUnitChange(changeEvent) { + const { sample } = this.props; + const { sampleID, amount } = changeEvent; + const componentIndex = sample.components.findIndex( + (component) => component.id === sampleID + ); + + const totalVolume = sample.amount_l; + + if (amount.unit === 'g' || amount.unit === 'l') { + sample.components[componentIndex].setAmount(amount, totalVolume); // volume given, update amount + } else if (amount.unit === 'mol') { + sample.components[componentIndex].setMol(amount, totalVolume); // amount given, update volume + } else if (amount.unit === 'mol/l') { + sample.components[componentIndex].setConc(amount); // starting conc. given, + } + + // update components ratio + sample.updateMixtureComponentEquivalent(); + } + + updateDensity(changeEvent) { + const { sample } = this.props; + const { sampleID, amount, lockColumn } = changeEvent; + const componentIndex = sample.components.findIndex( + (component) => component.id === sampleID + ); + + const totalVolume = sample.amount_l; + + sample.components[componentIndex].setDensity(amount, lockColumn, totalVolume); + // update components ratio + sample.updateMixtureComponentEquivalent(); + } + + updatePurity(changeEvent) { + const { sample } = this.props; + const { sampleID, amount } = changeEvent; + const purity = amount.value; + const componentIndex = this.props.sample.components.findIndex( + (component) => component.id === sampleID + ); + sample.components[componentIndex].setPurity(purity, sample.amount_l); + sample.updateMixtureComponentEquivalent(); + } + + updatedSampleForMetricsChange(changeEvent) { + const { sample } = this.props; + const { sampleID, metricUnit, metricPrefix } = changeEvent; + const componentIndex = this.props.sample.components.findIndex( + (component) => (component.parent_id === sampleID || component.id === sampleID) + ); + sample.components[componentIndex].setUnitMetrics(metricUnit, metricPrefix); + } + + dropSample(srcSample, tagMaterial, tagGroup, extLabel, isNewSample = false) { + const { sample } = this.state; + const { currentCollection } = UIStore.getState(); + let splitSample; + + if (srcSample instanceof Molecule || isNewSample) { + splitSample = Sample.buildNew(srcSample, currentCollection.id); + splitSample = new Component(splitSample); + } else if (srcSample instanceof Sample) { + splitSample = srcSample.buildChildWithoutCounter(); + splitSample = new Component(splitSample); + } + + splitSample.material_group = tagGroup; + + if (splitSample.sample_type === 'Mixture') { + ComponentsFetcher.fetchComponentsBySampleId(srcSample.id) + .then(async (components) => { + for (const component of components) { + const { component_properties, ...rest } = component; + const sampleData = { + ...rest, + ...component_properties + }; + const sampleComponent = new Component(sampleData); + sampleComponent.parent_id = splitSample.parent_id; + sampleComponent.material_group = tagGroup; + sampleComponent.reference = false; + if (tagGroup === 'solid') { + sampleComponent.setMolarity({ value: 0, unit: 'M' }, sample.amount_l, 'startingConc'); + sampleComponent.setAmount({ value: sampleComponent.amount_g, unit: 'g' }, sample.amount_l); + } else if (tagGroup === 'liquid') { + sampleComponent.setAmount({ value: sampleComponent.amount_l, unit: 'l' }, sample.amount_l); + } + sampleComponent.id = `comp_${Math.random().toString(36).substr(2, 9)}`; + await sample.addMixtureComponent(sampleComponent); + sample.updateMixtureComponentEquivalent(); + } + this.props.onChange(sample); + }) + .catch((errorMessage) => { + console.error(errorMessage); + }); + } else { + sample.addMixtureComponent(splitSample); + sample.updateMixtureComponentEquivalent(); + this.props.onChange(sample); + } + } + + updateComponentName(changeEvent) { + const { sample } = this.props; + const { sampleID, newName } = changeEvent; + const componentIndex = this.props.sample.components.findIndex( + (component) => component.id === sampleID + ); + sample.components[componentIndex].name = newName; + + this.props.onChange(sample); + } + + dropMaterial(srcMat, srcGroup, tagMat, tagGroup, action) { + const { sample } = this.state; + sample.components = sample.components.map((component) => { + if (!(component instanceof Component)) { + return new Component(component); + } + return component; + }); + + if (action === 'move') { + sample.moveMaterial(srcMat, srcGroup, tagMat, tagGroup); + this.props.onChange(sample); + } else if (action === 'merge') { + sample.mergeComponents(srcMat, srcGroup, tagMat, tagGroup) + .then(() => { + this.props.onChange(sample); + }) + .catch((error) => { + console.error('Error merging components:', error); + }); + } + } + + deleteMixtureComponent(component) { + const { sample } = this.state; + sample.deleteMixtureComponent(component); + this.props.onChange(sample); + } + + switchAmount(materialGroup) { + const { lockAmountColumn, lockAmountColumnSolids } = this.state; + if (materialGroup === 'liquid') { + this.setState({ lockAmountColumn: !lockAmountColumn }); + } else if (materialGroup === 'solid') { + this.setState({ lockAmountColumnSolids: !lockAmountColumnSolids }); + } + } + + updateRatio(changeEvent) { + const { sample } = this.props; + const { + sampleID, newRatio, materialGroup + } = changeEvent; + const componentIndex = this.props.sample.components.findIndex( + (component) => component.id === sampleID + ); + const refIndex = this.props.sample.components.findIndex( + (component) => component.reference === true + ); + const referenceMoles = sample.components[refIndex].amount_mol; + const totalVolume = sample.amount_l; + + sample.components[componentIndex].updateRatio(newRatio, materialGroup, totalVolume, referenceMoles); + + sample.updateMixtureMolecularWeight(); + + this.props.onChange(sample); + } + + updateSampleForReferenceChanged(changeEvent) { + const { sample } = this.props; + const { sampleID } = changeEvent; + const componentIndex = this.props.sample.components.findIndex( + (component) => component.id === sampleID + ); + + sample.setReferenceComponent(componentIndex); + } + + showModalWithMaterial(srcMat, srcGroup, tagMat, tagGroup) { + if (!tagMat && srcGroup !== tagGroup) { + this.setState({ + showModal: false, + droppedMaterial: null, + }); + return this.dropMaterial(srcMat, srcGroup, tagMat, tagGroup, 'move'); + } + this.setState({ + showModal: true, + droppedMaterial: { + srcMat, srcGroup, tagMat, tagGroup + }, + }); + } + + renderModal() { + return ( + + + +

Do you want to merge or move this component?

+
+ + + + + +
+ ); + } + + render() { + const { + sample, isOver, canDrop, enableComponentLabel, enableComponentPurity + } = this.props; + const style = { + padding: '2px 5px', + }; + if (isOver && canDrop) { + style.borderStyle = 'dashed'; + style.borderColor = '#337ab7'; + } else if (canDrop) { + style.borderStyle = 'dashed'; + } + const minPadding = { padding: '1px 2px 2px 0px' }; + + if (sample && sample.components) { + sample.components = sample.components.map((component) => (component instanceof Component ? component : new Component(component))); + } + + const liquids = sample.components + ? sample.components.filter((component) => component.material_group === 'liquid') + : []; + const solids = sample.components + ? sample.components.filter((component) => component.material_group === 'solid') + : []; + + return ( + + {this.renderModal()} + + this.onChangeComponent(changeEvent)} + switchAmount={this.switchAmount} + lockAmountColumn={this.state.lockAmountColumn} + lockAmountColumnSolids={this.state.lockAmountColumnSolids} + materialGroup="liquid" + showModalWithMaterial={this.showModalWithMaterial} + handleTabSelect={this.handleTabSelect} + activeTab={this.state.activeTab} + enableComponentLabel={enableComponentLabel} + enableComponentPurity={enableComponentPurity} + /> + + + this.onChangeComponent(changeEvent)} + switchAmount={this.switchAmount} + lockAmountColumn={this.state.lockAmountColumn} + lockAmountColumnSolids={this.state.lockAmountColumnSolids} + materialGroup="solid" + showModalWithMaterial={this.showModalWithMaterial} + handleTabSelect={this.handleTabSelect} + activeTab={this.state.activeTab} + /> + + + ); + } +} + +SampleDetailsComponents.propTypes = { + sample: PropTypes.instanceOf(Sample).isRequired, + onChange: PropTypes.func.isRequired, + isOver: PropTypes.bool.isRequired, + canDrop: PropTypes.bool.isRequired, + enableComponentLabel: PropTypes.bool.isRequired, + enableComponentPurity: PropTypes.bool.isRequired, +}; diff --git a/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponentsDnd.js b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponentsDnd.js new file mode 100644 index 0000000000..050b4e933c --- /dev/null +++ b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponentsDnd.js @@ -0,0 +1,130 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DropTarget } from 'react-dnd'; +import { DragDropItemTypes } from 'src/utilities/DndConst'; +import Sample from 'src/models/Sample'; +import SampleComponentsGroup from 'src/apps/mydb/elements/details/samples/propertiesTab/SampleComponentsGroup'; + +const target = { + drop(tagProps, monitor) { + const { dropSample, dropMaterial } = tagProps; + const srcItem = monitor.getItem(); + const srcType = monitor.getItemType(); + if (srcType === DragDropItemTypes.SAMPLE) { + dropSample( + srcItem.element, + tagProps.material, + tagProps.materialGroup, + ); + } else if (srcType === DragDropItemTypes.MOLECULE) { + dropSample( + srcItem.element, + tagProps.material, + tagProps.materialGroup, + null, + true, + ); + } else if (srcType === DragDropItemTypes.MATERIAL) { + dropMaterial( + srcItem.material, + srcItem.materialGroup, + tagProps.material, + tagProps.materialGroup, + 'move', + ); + } + }, + canDrop(tagProps, monitor) { + const srcType = monitor.getItemType(); + const isCorrectType = srcType === DragDropItemTypes.MATERIAL + || srcType === DragDropItemTypes.SAMPLE + || srcType === DragDropItemTypes.MOLECULE; + return isCorrectType; + }, +}; + +const collect = (connect, monitor) => ({ + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + canDrop: monitor.canDrop() +}); + +class SampleDetailsComponentsDnd extends React.Component { + render() { + const { + sample, + sampleComponents, + headIndex, + onChangeComponent, + dropSample, + dropMaterial, + deleteMixtureComponent, + isOver, + canDrop, + connectDropTarget, + lockAmountColumn, + lockAmountColumnSolids, + switchAmount, + materialGroup, + showModalWithMaterial, + activeTab, + handleTabSelect, + enableComponentLabel, + enableComponentPurity, + } = this.props; + const style = { + padding: '0px 0px', + }; + if (isOver && canDrop) { + style.borderStyle = 'dashed'; + style.borderColor = '#337ab7'; + } else if (canDrop) { + style.borderStyle = 'dashed'; + } + return connectDropTarget( +
+ +
+ ); + } +} + +export default DropTarget( + [DragDropItemTypes.SAMPLE, DragDropItemTypes.MOLECULE, DragDropItemTypes.MATERIAL], + target, + collect, +)(SampleDetailsComponentsDnd); + +SampleDetailsComponentsDnd.propTypes = { + sample: PropTypes.instanceOf(Sample).isRequired, + headIndex: PropTypes.number, + onChangeComponent: PropTypes.func.isRequired, + dropSample: PropTypes.func.isRequired, + dropMaterial: PropTypes.func.isRequired, + showModalWithMaterial: PropTypes.func.isRequired, + deleteMixtureComponent: PropTypes.func.isRequired, + isOver: PropTypes.bool.isRequired, + canDrop: PropTypes.bool.isRequired, + connectDropTarget: PropTypes.func.isRequired, + enableComponentLabel: PropTypes.bool.isRequired, + enableComponentPurity: PropTypes.bool.isRequired, +}; diff --git a/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleForm.js b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleForm.js index 2001639f88..5f2adab251 100644 --- a/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleForm.js +++ b/app/packs/src/apps/mydb/elements/details/samples/propertiesTab/SampleForm.js @@ -11,10 +11,11 @@ import DetailActions from 'src/stores/alt/actions/DetailActions'; import NumeralInputWithUnitsCompo from 'src/apps/mydb/elements/details/NumeralInputWithUnitsCompo'; import NumericInputUnit from 'src/apps/mydb/elements/details/NumericInputUnit'; import TextRangeWithAddon from 'src/apps/mydb/elements/details/samples/propertiesTab/TextRangeWithAddon'; -import { solventOptions } from 'src/components/staticDropdownOptions/options'; +import { solventOptions, SampleTypesOptions } from 'src/components/staticDropdownOptions/options'; import SampleDetailsSolvents from 'src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsSolvents'; import PrivateNoteElement from 'src/apps/mydb/elements/details/PrivateNoteElement'; import NotificationActions from 'src/stores/alt/actions/NotificationActions'; +import SampleDetailsComponents from 'src/apps/mydb/elements/details/samples/propertiesTab/SampleDetailsComponents'; export default class SampleForm extends React.Component { constructor(props) { @@ -23,6 +24,9 @@ export default class SampleForm extends React.Component { molarityBlocked: (props.sample.molarity_value || 0) <= 0, isMolNameLoading: false, moleculeFormulaWas: props.sample.molecule_formula, + selectedSampleType: props.sample.sample_type ? props.sample.sample_type : SampleTypesOptions[0], + enableComponentLabel: false, + enableComponentPurity: false, }; this.handleFieldChanged = this.handleFieldChanged.bind(this); @@ -34,6 +38,8 @@ export default class SampleForm extends React.Component { this.handleRangeChanged = this.handleRangeChanged.bind(this); this.handleSolventChanged = this.handleSolventChanged.bind(this); this.handleMetricsChange = this.handleMetricsChange.bind(this); + this.handleMixtureComponentChanged = this.handleMixtureComponentChanged.bind(this); + this.handleSampleTypeChanged = this.handleSampleTypeChanged.bind(this); } // eslint-disable-next-line camelcase @@ -41,6 +47,27 @@ export default class SampleForm extends React.Component { this.setState({ isMolNameLoading: false }); } + handleToggle = (key) => { + this.setState((prevState) => ({ + [key]: !prevState[key], + })); + }; + + renderCheckbox = (key, label, className) => { + const isChecked = this.state[key]; + + return ( + this.handleToggle(key)} + > + {label} + + ); + }; + formulaChanged() { return this.props.sample.molecule_formula !== this.state.moleculeFormulaWas; } @@ -54,6 +81,13 @@ export default class SampleForm extends React.Component { this.setState({ molarityBlocked: false }); } + handleSampleTypeChanged(sampleType) { + const { sample } = this.props; + sample.updateSampleType(sampleType.value); + this.setState({ selectedSampleType: sampleType.value }); + this.props.parent.setState({ sample }); + } + handleDensityChanged(density) { this.props.sample.setDensity(density); this.setState({ molarityBlocked: true }); @@ -67,6 +101,10 @@ export default class SampleForm extends React.Component { this.props.parent.setState({ sample }); } + handleMixtureComponentChanged(sample) { + this.props.parent.setState({ sample }); + } + showStructureEditor() { this.props.parent.setState({ showStructureEditor: true, @@ -425,7 +463,8 @@ export default class SampleForm extends React.Component { disabled = false, title = '', block = false, - notApplicable = false + notApplicable = false, + showInfoTooltipTotalVol = false ) { if (sample.contains_residues && unit === 'l') return false; const value = !isNaN(sample[field]) ? sample[field] : null; @@ -482,6 +521,7 @@ export default class SampleForm extends React.Component { onChange={(e) => this.handleFieldChanged(field, e)} onMetricsChange={(e) => this.handleMetricsChange(e)} id={`numInput_${field}`} + showInfoTooltipTotalVol={showInfoTooltipTotalVol} /> ); @@ -530,6 +570,34 @@ export default class SampleForm extends React.Component { ); } + totalAmount(sample) { + const isDisabled = !sample.can_update; + + if (!sample.isMethodDisabled('amount_value') && !sample.contains_residues) { + return this.numInput( + sample, + 'amount_l', + 'l', + ['m', 'u', 'n'], + 5, + 'Total volume', + 'l', + isDisabled, + '', + false, + false, + true + ); + } + + return ( + + Target Total Volume + + + ); + } + sampleAmount(sample) { const content = []; const isDisabled = !sample.can_update; @@ -683,14 +751,48 @@ export default class SampleForm extends React.Component { // eslint-disable-next-line class-methods-use-this assignAmountType(reaction, sample) { - // eslint-disable-next-line no-underscore-dangle - reaction._products.map((s) => { - if (s.id === sample.id) { - // eslint-disable-next-line no-param-reassign - sample.amountType = 'real'; - } - return sample; - }); + if (reaction._products && reaction._products.length > 0) { + // eslint-disable-next-line no-underscore-dangle + reaction._products.map((s) => { + if (s.id === sample.id) { + // eslint-disable-next-line no-param-reassign + sample.amountType = 'real'; + } + return sample; + }); + } + } + + sampleTypeInput() { + const { sample } = this.props; + return ( + + Sample Type +