diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 97fb15ecb8..0c41b9a91b 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.0", + "version": "7.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.0", + "version": "7.1.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/package.json b/packages/components/package.json index 0a91aef017..65e8c8bc11 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.0", + "version": "7.1.1", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index d41b997610..e31814bf10 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,14 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 7.1.1 +*Released*: 8 December 2025 +- Sample Amount/Units polish: part 2 + - Introduced a two-tier unit selection system (Amount Type, Display Units) in sample designer + - Added new measurement units (ug, ng) with updated display precision for existing units + - Added a new formsy rule `sampleAmount` for validating sample amount/units input on forms + - Disallow large amount (>1.79769E308) for amounts input on form, editable grid, and sample storage editor + ### version 7.1.0 *Released*: 3 December 2025 - ChartBuilderModal diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx index 15c7885e52..464a7ee44f 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx @@ -369,8 +369,7 @@ export class SampleTypeDesignerImpl extends React.PureComponent this.saveDomain(false, comment ?? auditUserComment)); @@ -386,7 +385,7 @@ export class SampleTypeDesignerImpl extends React.PureComponent 0) { exception = 'Duplicate parent alias header found: ' + getDuplicateAlias(model.parentAliases, true).join(', '); - } else if (!model.isMetricUnitValid(metricUnitRequired)) { + } else if (!model.isMetricUnitValid()) { exception = metricUnitProps?.metricUnitLabel + ' field is required.'; } else { exception = model.domain.getFirstFieldError(); diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx index 06fa3e49ff..deef26101f 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx @@ -9,8 +9,9 @@ import { DomainDetails, DomainPanelStatus } from '../models'; import { getTestAPIWrapper } from '../../../APIWrapper'; -import { SampleTypePropertiesPanel } from './SampleTypePropertiesPanel'; +import { getValidUnitKinds, SampleTypePropertiesPanel, UnitKinds } from './SampleTypePropertiesPanel'; import { SampleTypeModel } from './models'; +import { UNITS_KIND } from '../../../util/measurement'; describe('SampleTypePropertiesPanel', () => { const BASE_PROPS = { @@ -114,15 +115,15 @@ describe('SampleTypePropertiesPanel', () => { container = renderWithAppContext( ); }); @@ -154,7 +155,6 @@ describe('SampleTypePropertiesPanel', () => { metricUnitProps={{ includeMetricUnitProperty: true, metricUnitLabel: 'Display Units', - metricUnitRequired: true, metricUnitHelpMsg: 'Sample storage volume will be displayed using the selected metric unit.', metricUnitOptions: [ { id: 'mL', label: 'ml' }, @@ -180,9 +180,9 @@ describe('SampleTypePropertiesPanel', () => { renderWithAppContext( ); }); @@ -201,9 +201,9 @@ describe('SampleTypePropertiesPanel', () => { renderWithAppContext( ); }); @@ -255,11 +255,11 @@ describe('SampleTypePropertiesPanel', () => { renderWithAppContext( ); @@ -271,3 +271,30 @@ describe('SampleTypePropertiesPanel', () => { expect(aliquotField.textContent).toEqual('Aliquot Naming Pattern'); }); }); + +describe('getValidUnitKinds', () => { + test('returns all unit kinds when lockUnitKind and metricUnit are undefined', () => { + const result = getValidUnitKinds(); + expect(result).toEqual(Object.values(UnitKinds)); + }); + + test('returns all unit kinds when lockUnitKind is false', () => { + const result = getValidUnitKinds(false, 'mL'); + expect(result).toEqual(Object.values(UnitKinds)); + }); + + test('returns NONE and the unit kind matching the metricUnit when lockUnitKind is true', () => { + const result = getValidUnitKinds(true, 'mL'); + expect(result).toEqual([UnitKinds[UNITS_KIND.NONE], UnitKinds[UNITS_KIND.VOLUME]]); + }); + + test('returns only NONE when lockUnitKind is true and metricUnit is invalid', () => { + const result = getValidUnitKinds(true, 'invalidUnit'); + expect(result).toEqual([UnitKinds[UNITS_KIND.NONE]]); + }); + + test('returns only all when lockUnitKind is true and metricUnit is undefined', () => { + const result = getValidUnitKinds(true); + expect(result).toEqual(Object.values(UnitKinds)); + }); +}); diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index 303e7c631b..3d9ea635f0 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -49,6 +49,13 @@ import { ComponentsAPIWrapper, getDefaultAPIWrapper } from '../../../APIWrapper' import { UniqueIdBanner } from './UniqueIdBanner'; import { AliquotNamePatternProps, DEFAULT_ALIQUOT_NAMING_PATTERN, MetricUnitProps, SampleTypeModel } from './models'; +import { + getMeasurementUnit, + getMetricUnitOptions, + getMetricUnitOptionsFromKind, + UNITS_KIND, +} from '../../../util/measurement'; +import { Alert } from '../../base/Alert'; const PROPERTIES_HEADER_ID = 'sample-type-properties-hdr'; const ALIQUOT_HELP_LINK = getHelpLink('aliquotIDs'); @@ -67,6 +74,41 @@ const AddEntityHelpTip: FC<{ parentageLabel?: string }> = memo(({ parentageLabel ); }); + +export const UnitKinds: Record = { + [UNITS_KIND.NONE]: { + value: UNITS_KIND.NONE, + label: 'Any', + hideSubSelect: true, + msg: "Amounts can be entered in any unit and won't be converted when stored or displayed.", + }, + [UNITS_KIND.MASS]: { + value: UNITS_KIND.MASS, + label: 'Mass', + }, + [UNITS_KIND.VOLUME]: { + value: UNITS_KIND.VOLUME, + label: 'Volume', + }, + [UNITS_KIND.COUNT]: { + value: UNITS_KIND.COUNT, + label: 'Other', + hideSubSelect: true, + msg: "Amounts can be entered as bottles, blocks, boxes, cells, kits, packs, pieces, slides, tests, or unit and won't be converted.", + }, +}; + +export const getValidUnitKinds = (lockUnitKind?: boolean, metricUnit?: string): UnitKindType[] => { + if (!lockUnitKind || !metricUnit) return Object.values(UnitKinds); + + const validOptions = [UnitKinds[UNITS_KIND.NONE]]; // any unit can switch to no unit type + + const unitKind = getMeasurementUnit(metricUnit)?.kind; + if (unitKind) validOptions.push(UnitKinds[unitKind]); + + return validOptions; +}; + AddEntityHelpTip.displayName = 'AddEntityHelpTip'; const AutoLinkDataToStudyHelpTip: FC = () => ( @@ -147,23 +189,35 @@ interface EntityProps { nounSingular?: string; } +interface UnitKindType { + hideSubSelect?: boolean; + label: string; + msg?: string; + value: string; +} + interface State { containers: Container[]; isValid: boolean; loadingError: string; + metricUnitKind: UnitKindType; + originalUnit: string; prefix: string; sampleTypeCategory: string; + unitChangeWarning: string; + validMetricUnitOptions: UnitKindType[]; + validUnitKinds: UnitKindType[]; } -type Props = OwnProps & EntityProps & BasePropertiesPanelProps; +type Props = BasePropertiesPanelProps & EntityProps & OwnProps; -class SampleTypePropertiesPanelImpl extends PureComponent { +class SampleTypePropertiesPanelImpl extends PureComponent { static defaultProps = { api: getDefaultAPIWrapper(), nounSingular: SAMPLE_SET_DISPLAY_TEXT, nounPlural: SAMPLE_SET_DISPLAY_TEXT + 's', nameExpressionInfoUrl: getHelpLink('sampleIDs'), - // eslint-disable-next-line no-template-curly-in-string + nameExpressionPlaceholder: 'Enter a naming pattern (e.g., S-${now:date}-${dailySampleCount})', appPropertiesOnly: false, showLinkToStudy: true, @@ -186,10 +240,15 @@ class SampleTypePropertiesPanelImpl extends PureComponent => { - const { api, model } = this.props; + const { api, model, metricUnitProps } = this.props; try { const result = await api.query.selectRows({ @@ -198,7 +257,19 @@ class SampleTypePropertiesPanelImpl extends PureComponent ({ isValid }), @@ -248,6 +318,38 @@ class SampleTypePropertiesPanelImpl extends PureComponent { + const { originalUnit } = this.state; + const unitKind = value ? UnitKinds[value] : null; + const unitOptions = getMetricUnitOptionsFromKind(unitKind?.value, true); + let unitToSelect = value === UNITS_KIND.COUNT ? 'unit' : value === UNITS_KIND.NONE ? '' : null; + let unitChangeWarning = null; + if (originalUnit && value === UNITS_KIND.NONE) + unitChangeWarning = + "Once switched to 'Any' amount type, you may not be able to switch back to '" + + getMeasurementUnit(originalUnit)?.kind + + "' amount type."; + + if (originalUnit && getMeasurementUnit(originalUnit)?.kind === value) { + unitToSelect = originalUnit; + } + this.updateValidStatus(this.props.model.set('metricUnit', unitToSelect) as SampleTypeModel); + this.setState({ metricUnitKind: unitKind, validMetricUnitOptions: unitOptions, unitChangeWarning }); + }; + + onUnitChange = (key: string, value: any): void => { + const { model } = this.props; + const { metricUnitKind } = this.state; + if (value) { + const unitOptions = getMetricUnitOptions(value, true); + const unitKind = getMeasurementUnit(value)?.kind ?? (model.isNew() ? null : UNITS_KIND.NONE); + if (unitKind && unitKind !== metricUnitKind?.value) { + this.setState({ metricUnitKind: UnitKinds[unitKind], validMetricUnitOptions: unitOptions }); + } + } + this.updateValidStatus(this.props.model.set('metricUnit', value) as SampleTypeModel); + }; + onNameFieldHover = (): void => { this.props.onNameFieldHover?.(); }; @@ -278,7 +380,17 @@ class SampleTypePropertiesPanelImpl extends PureComponent
@@ -337,25 +448,24 @@ class SampleTypePropertiesPanelImpl extends PureComponent {appPropertiesOnly && } {showAliquotNameExpression && (

Pattern used for generating unique Ids for Aliquots.

@@ -369,21 +479,22 @@ class SampleTypePropertiesPanelImpl extends PureComponent {model.aliquotNameExpression && ( )}

More info

} + label="Aliquot Naming Pattern" />
@@ -393,11 +504,11 @@ class SampleTypePropertiesPanelImpl extends PureComponent) => { this.onFieldChange(e.target.name, e.target.value); }} + placeholder={aliquotNameExpressionPlaceholder ?? ALIQUOT_NAME_PLACEHOLDER} + type="text" value={model.aliquotNameExpression} />
@@ -405,29 +516,29 @@ class SampleTypePropertiesPanelImpl extends PureComponent - + } - includeSampleSet + hideRequiredCheck={!appPropertiesOnly} + idPrefix="sampletype-parent-import-alias-" includeDataClass={includeDataClasses && !useSeparateDataClassesAliasMenu} + includeSampleSet + parentAliases={model.parentAliases} + schema={SCHEMAS.SAMPLE_SETS.SCHEMA} showAddBtn={showAddParentAlias} - hideRequiredCheck={!appPropertiesOnly} /> )} {showDataClass && ( } - includeSampleSet={false} + idPrefix="sampletype-parent-import-alias-" includeDataClass + includeSampleSet={false} + parentAliases={model.parentAliases} + schema={SCHEMAS.DATA_CLASSES.SCHEMA} showAddBtn /> )} @@ -436,15 +547,15 @@ class SampleTypePropertiesPanelImpl extends PureComponent
} + label="Auto-Link Data to Study" />
@@ -452,17 +563,17 @@ class SampleTypePropertiesPanelImpl extends PureComponent
} + label="Linked Dataset Category" />
@@ -478,71 +589,101 @@ class SampleTypePropertiesPanelImpl extends PureComponent
{includeMetricUnitProperty && ( -
-
- -
-
- {metricUnitProps?.metricUnitOptions ? ( + <> +
+
+ +
+
{ - this.onFieldChange( + this.onMetricUnitKindChange( name, formValue === undefined && option ? option.id : formValue ); }} - placeholder="Select a unit..." - value={model.metricUnit} - /> - ) : ( - ) => { - this.onFieldChange(e.target.name, e.target.value); - }} + options={validUnitKinds} + placeholder="Select a type..." + required + value={metricUnitKind} /> - )} +
-
+ {!metricUnitKind?.hideSubSelect && ( +
+
+ +
+
+ { + this.onUnitChange( + name, + formValue === undefined && option ? option.id : formValue + ); + }} + options={validMetricUnitOptions} + placeholder="Select a unit..." + required + value={model.metricUnit} + /> +
+
+ )} + {metricUnitKind?.msg && ( +
+
+
+ {metricUnitKind.msg} +
+
+ )} + {unitChangeWarning && ( +
+
+
+ {unitChangeWarning} +
+
+ )} + )} )} {!isCommunityDistribution() && (
- } /> + } label="Barcodes" />
- +
)} diff --git a/packages/components/src/internal/components/domainproperties/samples/models.ts b/packages/components/src/internal/components/domainproperties/samples/models.ts index 67e27c1fbc..cabd917bde 100644 --- a/packages/components/src/internal/components/domainproperties/samples/models.ts +++ b/packages/components/src/internal/components/domainproperties/samples/models.ts @@ -4,7 +4,6 @@ import { DomainDesign, DomainDetails, IDomainField } from '../models'; import { IImportAlias, IParentAlias } from '../../entities/models'; import { getDuplicateAlias, parentAliasInvalid } from '../utils'; -// eslint-disable-next-line no-template-curly-in-string export const DEFAULT_ALIQUOT_NAMING_PATTERN = '${${AliquotedFrom}-:withCounter}'; export class SampleTypeModel extends Record({ @@ -52,6 +51,8 @@ export class SampleTypeModel extends Record({ importAliases = { ...aliases }; } + let metricUnit = options?.get('metricUnit') || undefined; + if (!metricUnit && options?.get('rowId')) metricUnit = ''; // use '' instead of undefined for existing designer with no (Any) display unit return new SampleTypeModel({ ...options?.toJS(), aliquotNameExpression: options?.get('aliquotNameExpression') || DEFAULT_ALIQUOT_NAMING_PATTERN, @@ -59,7 +60,7 @@ export class SampleTypeModel extends Record({ nameReadOnly: raw?.nameReadOnly, importAliases, labelColor: options?.get('labelColor') || undefined, // helps to convert null to undefined - metricUnit: options?.get('metricUnit') || undefined, + metricUnit, domain: raw?.domainDesign ?? DomainDesign.create({}), }); } @@ -73,18 +74,18 @@ export class SampleTypeModel extends Record({ return !this.rowId; } - isValid(defaultNameFieldConfig?: Partial, metricUnitRequired?: boolean) { + isValid(defaultNameFieldConfig?: Partial) { return ( this.hasValidProperties() && !this.hasInvalidNameField(defaultNameFieldConfig) && getDuplicateAlias(this.parentAliases, true).size === 0 && !this.domain.hasInvalidFields() && - this.isMetricUnitValid(metricUnitRequired) + this.isMetricUnitValid() ); } - isMetricUnitValid(metricUnitRequired?: boolean) { - return !metricUnitRequired || this.metricUnit != null; + isMetricUnitValid() { + return this.metricUnit != null; } hasValidProperties(): boolean { @@ -106,10 +107,10 @@ export class SampleTypeModel extends Record({ export interface MetricUnitProps { includeMetricUnitProperty?: boolean; + lockUnitKind?: boolean; metricUnitHelpMsg?: string; metricUnitLabel?: string; - metricUnitOptions?: any[]; - metricUnitRequired?: boolean; + metricUnitOptions?: { label: string; value: string }[]; } export interface AliquotNamePatternProps { diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts index b5c1ee6f13..b3253cdfe9 100644 --- a/packages/components/src/internal/components/editable/utils.ts +++ b/packages/components/src/internal/components/editable/utils.ts @@ -15,7 +15,7 @@ import { SelectInputOption, SelectInputProps } from '../forms/input/SelectInput' import { QuerySelectOwnProps } from '../forms/QuerySelect'; -import { isBoolean, isFloat, isInteger } from '../../util/utils'; +import { getInvalidSampleAmountMessage, isBoolean, isFloat, isInteger } from '../../util/utils'; import { incrementClientSideMetricCount } from '../../actions'; import { CellMessage } from './models'; @@ -64,8 +64,9 @@ export const getValidatedEditableGridValue = (origValue: any, col: QueryColumn): message = 'Invalid integer'; } else if (jsonType === 'float' && !isFloat(value)) { message = 'Invalid decimal'; - } else if (NON_NEGATIVE_NUMBER_CONCEPT_URI === col?.conceptURI && Number(value) < 0) { - message = col.caption + ' must be non-negative'; + } else if (NON_NEGATIVE_NUMBER_CONCEPT_URI === col?.conceptURI) { + if (Number(value) < 0) message = col.caption + ' must be non-negative'; + else if (col?.fieldKey?.toLowerCase() === 'storedamount') message = getInvalidSampleAmountMessage(value); } else if (jsonType === 'string' && scale) { if (value.toString().trim().length > scale) message = value.toString().trim().length + '/' + scale + ' characters'; diff --git a/packages/components/src/internal/components/forms/formsy/formsyRules.ts b/packages/components/src/internal/components/forms/formsy/formsyRules.ts index 75ca2a3567..f745027461 100644 --- a/packages/components/src/internal/components/forms/formsy/formsyRules.ts +++ b/packages/components/src/internal/components/forms/formsy/formsyRules.ts @@ -3,6 +3,7 @@ // Repository: https://github.com/formsy/formsy-react/tree/0226fab133a25 import { ValidationFunction, Values } from './types'; import { isString, isValueNullOrUndefined } from './utils'; +import { isAllowedSampleAmount } from '../../../util/utils'; function isExisty(value: V): boolean { return !isValueNullOrUndefined(value); @@ -61,6 +62,7 @@ export const formsyRules: Record> = { matchRegexp, maxLength: (_values, value: string, length: number) => !isExisty(value) || value.length <= length, minLength: (_values, value: string, length: number) => !isExisty(value) || isEmpty(value) || value.length >= length, + sampleAmount: (values, value: V) => isAllowedSampleAmount(value), }; // Formerly "addValidationRule". Renamed so it is clear from the name that this applies only to Formsy. diff --git a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx index 06797ffe05..60b9649288 100644 --- a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx +++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx @@ -6,10 +6,11 @@ import { QuerySelect } from '../QuerySelect'; import { getContainerFilterForLookups } from '../../../query/api'; import { FieldLabel } from '../FieldLabel'; import { InputRendererProps } from './types'; -import { caseInsensitive, generateId } from '../../../util/utils'; +import { caseInsensitive, generateId, getInvalidSampleAmountMessage } from '../../../util/utils'; import { FormsyInput } from './FormsyReactComponents'; import { Operation } from '../../../../public/QueryColumn'; import { STORED_AMOUNT_FIELDS } from '../../samples/constants'; +import { Alert } from '../../base/Alert'; export const AmountUnitInput: FC = memo(props => { const { @@ -24,6 +25,7 @@ export const AmountUnitInput: FC = memo(props => { queryFilters, } = props; const [disabled, setDisabled] = useState(initiallyDisabled && allowFieldDisable); + const [amountError, setAmountError] = useState(undefined); const amountCol = allColumns.find(col => col.name.toLowerCase() === STORED_AMOUNT_FIELDS.AMOUNT.toLowerCase()); const unitCol = allColumns.find(col => col.name.toLowerCase() === STORED_AMOUNT_FIELDS.UNITS.toLowerCase()); @@ -43,59 +45,72 @@ export const AmountUnitInput: FC = memo(props => { }); }, [setDisabled]); + const onAmountChange = useCallback((name: string, value: any) => { + const errorMsg = getInvalidSampleAmountMessage(value); + setAmountError(errorMsg); + }, []); + if (!amountCol || !unitCol) { return null; } return ( -
- - - - {allowFieldDisable && ( - - )} -
+ <> +
+ + + + {allowFieldDisable && ( + + )} +
+ {amountError} + ); }); diff --git a/packages/components/src/internal/components/forms/input/TextInput.tsx b/packages/components/src/internal/components/forms/input/TextInput.tsx index a0e136a032..d4f9cce858 100644 --- a/packages/components/src/internal/components/forms/input/TextInput.tsx +++ b/packages/components/src/internal/components/forms/input/TextInput.tsx @@ -30,7 +30,7 @@ export interface TextInputProps extends DisableableInputProps, Omit void; + onChange?: (name: string, value: any) => void; queryColumn: QueryColumn; renderFieldLabel?: (queryColumn: QueryColumn, label?: string, description?: string) => ReactNode; showLabel?: boolean; @@ -107,7 +107,7 @@ export class TextInput extends DisableableInput onChange = (name: string, value: any): void => { this.setState({ inputValue: value }); - this.props.onChange?.(value); + this.props.onChange?.(name, value); }; render() { @@ -117,6 +117,7 @@ export class TextInput extends DisableableInput // Extract TextInputProps const { addLabelAsterisk, + disableInput, labelClassName, renderFieldLabel, queryColumn, @@ -142,7 +143,7 @@ export class TextInput extends DisableableInput required={queryColumn.required} {...inputProps} componentRef={this.textInput} - disabled={this.state.isDisabled || this.props.disableInput} + disabled={this.state.isDisabled || disableInput} help={help} label={this.renderLabel()} labelClassName={showLabel ? labelClassName : 'hide-label'} diff --git a/packages/components/src/internal/components/samples/SampleAmountEditModal.test.tsx b/packages/components/src/internal/components/samples/SampleAmountEditModal.test.tsx index 2ab5762c04..daa5612177 100644 --- a/packages/components/src/internal/components/samples/SampleAmountEditModal.test.tsx +++ b/packages/components/src/internal/components/samples/SampleAmountEditModal.test.tsx @@ -230,5 +230,7 @@ describe('isValid', () => { expect(isValid(10, 'uL')).toBe(true); expect(isValid(0.1, 'uL')).toBe(true); expect(isValid(10.000000001, 'uL')).toBe(true); + expect(isValid(-1, 'uL')).toBe(false); + expect(isValid(Infinity, 'uL')).toBe(false); }); }); diff --git a/packages/components/src/internal/components/samples/SampleAmountEditModal.tsx b/packages/components/src/internal/components/samples/SampleAmountEditModal.tsx index bb72cf6f1c..5a1184701e 100644 --- a/packages/components/src/internal/components/samples/SampleAmountEditModal.tsx +++ b/packages/components/src/internal/components/samples/SampleAmountEditModal.tsx @@ -1,7 +1,7 @@ import React, { FC, memo, useCallback, useState } from 'react'; import { SchemaQuery } from '../../../public/SchemaQuery'; -import { caseInsensitive } from '../../util/utils'; +import { caseInsensitive, isAllowedSampleAmount } from '../../util/utils'; import { Alert } from '../base/Alert'; import { UnitModel } from '../../util/measurement'; @@ -32,7 +32,7 @@ export const isValid = (amount: number, units: string): boolean => { const hasNeither = !hasAmount && !hasUnits; if (hasBoth) { - return amount >= 0; + return isAllowedSampleAmount(amount); } return hasNeither; }; diff --git a/packages/components/src/internal/components/samples/StorageAmountInput.test.tsx b/packages/components/src/internal/components/samples/StorageAmountInput.test.tsx index e5e5d3aafb..ee7d82a3fa 100644 --- a/packages/components/src/internal/components/samples/StorageAmountInput.test.tsx +++ b/packages/components/src/internal/components/samples/StorageAmountInput.test.tsx @@ -128,4 +128,22 @@ describe('StorageAmountInput', () => { 'Amount must be a non-negative value.' ); }); + + test('Large amount error', () => { + const unit = 'uL'; + const model = new UnitModel(1e310, unit); + render( + + ); + + expect(document.querySelector('input.storage-amount-input')).toHaveProperty('value', 'Infinity'); + expect(document.querySelector('.storage-item-precision-alert').textContent).toBe( + 'Infinite or extremely large values are not allowed for amount.' + ); + }); }); diff --git a/packages/components/src/internal/components/samples/StorageAmountInput.tsx b/packages/components/src/internal/components/samples/StorageAmountInput.tsx index 6aaf98fe98..2af16b90af 100644 --- a/packages/components/src/internal/components/samples/StorageAmountInput.tsx +++ b/packages/components/src/internal/components/samples/StorageAmountInput.tsx @@ -1,21 +1,15 @@ -import React, { FC, memo, useCallback } from 'react'; +import React, { FC, memo, useCallback, useState } from 'react'; import { Alert } from '../base/Alert'; import { SelectInput, SelectInputOption } from '../forms/input/SelectInput'; import { LabelHelpTip } from '../base/LabelHelpTip'; import { + getMeasurementUnit, getMetricUnitOptions, - getVolumeMinStep, isMeasurementUnitIgnoreCase, - MEASUREMENT_UNITS, UnitModel, } from '../../util/measurement'; - -const negativeValueMessage = ( - - Amount must be a non-negative value. - -); +import { getInvalidSampleAmountMessage } from '../../util/utils'; interface Props { amountChangedHandler: (amount: string) => void; @@ -32,7 +26,8 @@ export const StorageAmountInput: FC = memo(props => { const { className, model, preferredUnit, inputName, label, tipText, amountChangedHandler, unitsChangedHandler } = props; - const isNegativeValue = model?.value < 0; + const [amountInput, setAmountInput] = useState(model?.value?.toString() || ''); + const unitText = model?.unit?.label || model.unitStr; let preferredUnitMessage; @@ -42,7 +37,7 @@ export const StorageAmountInput: FC = memo(props => { unitDisplay = {unitText || preferredUnit}; } // If unitText is provided and not a supported unit type, allow editing as text - else if (unitText && !MEASUREMENT_UNITS.hasOwnProperty(unitText.toLowerCase())) { + else if (unitText && !getMeasurementUnit(unitText)) { unitDisplay = ( = memo(props => { } } - const onChange = useCallback(event => amountChangedHandler(event?.target?.value), [amountChangedHandler]); + const onChange = useCallback( + event => { + const newValue = event?.target?.value; + amountChangedHandler(newValue); + setAmountInput(newValue); + }, + [amountChangedHandler] + ); + const containerClassName = className ?? 'form-group storage-item-check-in-sampletype-row '; return ( <> @@ -98,18 +101,18 @@ export const StorageAmountInput: FC = memo(props => { {unitDisplay} {preferredUnitMessage}
- {isNegativeValue ? negativeValueMessage : undefined} + + {getInvalidSampleAmountMessage(amountInput)} + ); }); diff --git a/packages/components/src/internal/util/measurement.test.ts b/packages/components/src/internal/util/measurement.test.ts index 48feba594e..3a5dd7b81b 100644 --- a/packages/components/src/internal/util/measurement.test.ts +++ b/packages/components/src/internal/util/measurement.test.ts @@ -1,4 +1,11 @@ -import { areUnitsCompatible, getAltUnitKeys, getMetricUnitOptions, UnitModel } from './measurement'; +import { + areUnitsCompatible, + getAltUnitKeys, + getMeasurementUnit, + getMetricUnitOptions, + MEASUREMENT_UNITS, + UnitModel, +} from './measurement'; describe('UnitModel', () => { test('constructor and operators', () => { @@ -8,7 +15,7 @@ describe('UnitModel', () => { expect(new UnitModel(99999, 'uL').as('L').toString()).toBe('0.099999 L'); expect(new UnitModel(99999.133, 'uL').as('L').toString()).toBe('0.099999133 L'); expect(new UnitModel(99999.13345678, 'uL').as('L').toString()).toBe('0.099999133 L'); - expect(new UnitModel(99999.13345678, 'mg').as('kg').toString()).toBe('0.099999133457 kg'); + expect(new UnitModel(99999.13345678, 'mg').as('kg').toString()).toBe('0.09999913345678 kg'); expect(new UnitModel(10, 'mL').as('L').toString()).toBe('0.01 L'); expect(new UnitModel(undefined, 'mL').as('L').toString()).toBe('undefined L'); expect(new UnitModel(0.0005, 'mL').as('mL').toString()).toBe('0.0005 mL'); @@ -127,9 +134,9 @@ describe('MetricUnit utils', () => { ]) ); - expect(getMetricUnitOptions(null).length).toBe(7); - expect(getMetricUnitOptions('').length).toBe(7); - expect(getMetricUnitOptions('bad').length).toBe(7); + expect(getMetricUnitOptions(null).length).toBe(18); + expect(getMetricUnitOptions('').length).toBe(18); + expect(getMetricUnitOptions('bad').length).toBe(18); }); test('getAltUnitKeys', () => { @@ -137,16 +144,42 @@ describe('MetricUnit utils', () => { expect(getAltUnitKeys('uL')).toEqual(expectedUlOptions); expect(getAltUnitKeys('mL')).toEqual(expectedUlOptions); - const expectedGOptions = ['g', 'mg', 'kg']; + const expectedGOptions = ['g', 'mg', 'kg', 'ug', 'ng']; expect(getAltUnitKeys('g')).toEqual(expectedGOptions); expect(getAltUnitKeys('kg')).toEqual(expectedGOptions); - expect(getAltUnitKeys('unit')).toEqual(['unit']); + expect(getAltUnitKeys('unit')).toEqual([ + 'blocks', + 'bottles', + 'boxes', + 'cells', + 'kits', + 'packs', + 'pieces', + 'slides', + 'tests', + 'unit', + ]); // include all options when no unitTypeStr or an invalid unitTypeStr is provided - expect(getAltUnitKeys(null).length).toBe(7); - expect(getAltUnitKeys('').length).toBe(7); - expect(getAltUnitKeys('bad').length).toBe(7); + expect(getAltUnitKeys(null).length).toBe(18); + expect(getAltUnitKeys('').length).toBe(18); + expect(getAltUnitKeys('bad').length).toBe(18); + }); + + test('getMeasurementUnit', () => { + expect(getMeasurementUnit(undefined)).toBeNull(); + expect(getMeasurementUnit('')).toBeNull(); + expect(getMeasurementUnit('invalidUnit')).toBeNull(); + expect(getMeasurementUnit('mL')).toEqual(MEASUREMENT_UNITS.ml); + expect(getMeasurementUnit('ML')).toEqual(MEASUREMENT_UNITS.ml); + const unit = getMeasurementUnit('pieces'); + expect(unit).toEqual({ + ...MEASUREMENT_UNITS.unit, + label: 'pieces', + longLabelSingular: 'pieces', + longLabelPlural: 'pieces', + }); }); }); diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index dbba81dca2..994103be1a 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -3,6 +3,7 @@ import { immerable } from 'immer'; export enum UNITS_KIND { COUNT = 'Count', MASS = 'Mass', + NONE = 'None', VOLUME = 'Volume', } @@ -14,7 +15,7 @@ export class UnitModel { readonly unit?: MeasurementUnit; constructor(value: number, unitStr: string) { - const unit = MEASUREMENT_UNITS[unitStr?.toLowerCase()] || null; + const unit = getMeasurementUnit(unitStr) || null; Object.assign(this, { value, unitStr, unit }); } @@ -27,7 +28,7 @@ export class UnitModel { return this.unit == null; } - const newUnit: MeasurementUnit = MEASUREMENT_UNITS[newUnitStr.toLowerCase()]; + const newUnit: MeasurementUnit = getMeasurementUnit(newUnitStr); return newUnit?.kind == this.unit?.kind; } @@ -36,7 +37,7 @@ export class UnitModel { throw new Error('Cannot convert to "' + newUnitStr + '"'); } - const newUnit = MEASUREMENT_UNITS[newUnitStr?.toLowerCase()]; + const newUnit = getMeasurementUnit(newUnitStr); if (!newUnit) { throw new Error('Unit type not supported "' + newUnitStr + '"'); } @@ -88,6 +89,7 @@ export class UnitModel { } export interface MeasurementUnit { + altLabels?: string[]; baseUnit: string; // Number of decimal places allowed when unit is displayed displayPrecision: number; @@ -98,7 +100,7 @@ export interface MeasurementUnit { ratio: number; } -export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { +export const MEASUREMENT_UNITS: Record = { g: { label: 'g', baseUnit: 'g', @@ -106,7 +108,7 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { longLabelPlural: 'grams', kind: UNITS_KIND.MASS, ratio: 1, - displayPrecision: 9, // enable smallest precision of ng + displayPrecision: 12, // enable smallest precision of pg and allow up to 3 decimal places for ng }, mg: { baseUnit: 'g', @@ -115,7 +117,7 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { longLabelPlural: 'milligrams', kind: UNITS_KIND.MASS, ratio: 0.001, - displayPrecision: 6, + displayPrecision: 9, }, kg: { baseUnit: 'g', @@ -124,7 +126,25 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { longLabelPlural: 'kilograms', kind: UNITS_KIND.MASS, ratio: 1000, - displayPrecision: 12, // enable smallest precision of ng + displayPrecision: 15, + }, + ug: { + baseUnit: 'g', + label: 'ug', + longLabelSingular: 'microgram', + longLabelPlural: 'micrograms', + kind: UNITS_KIND.MASS, + ratio: 0.000001, + displayPrecision: 6, + }, + ng: { + baseUnit: 'g', + label: 'ng', + longLabelSingular: 'nanogram', + longLabelPlural: 'nanograms', + kind: UNITS_KIND.MASS, + ratio: 0.000000001, + displayPrecision: 3, }, ml: { baseUnit: 'mL', @@ -161,9 +181,29 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { kind: UNITS_KIND.COUNT, ratio: 1, displayPrecision: 2, + altLabels: ['blocks', 'bottles', 'boxes', 'cells', 'kits', 'packs', 'pieces', 'slides', 'tests', 'unit'], }, }; +export function getMeasurementUnit(unitStr: string): MeasurementUnit { + if (!unitStr) return null; + + const unit = MEASUREMENT_UNITS[unitStr?.toLowerCase()]; + if (unit) return unit; + + const unitStrLc = unitStr.toLowerCase(); + if (MEASUREMENT_UNITS.unit.altLabels.indexOf(unitStrLc) > -1) { + return { + ...MEASUREMENT_UNITS.unit, + label: unitStrLc, + longLabelSingular: unitStrLc, + longLabelPlural: unitStrLc, + }; + } + + return null; +} + /** * @param unitAStr * @param unitBStr @@ -181,36 +221,67 @@ export function areUnitsCompatible(unitAStr: string, unitBStr: string) { if (!unitAStr && unitBStr) { return false; } - const unitA: MeasurementUnit = MEASUREMENT_UNITS[unitAStr.toLowerCase()]; - const unitB: MeasurementUnit = MEASUREMENT_UNITS[unitBStr.toLowerCase()]; + const unitA: MeasurementUnit = getMeasurementUnit(unitAStr); + const unitB: MeasurementUnit = getMeasurementUnit(unitBStr); if (!unitA || !unitB) { return false; } - return unitA.kind == unitB.kind; + return unitA.kind === unitB.kind; } -export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolean): any[] { - const unit: MeasurementUnit = MEASUREMENT_UNITS[metricUnit?.toLowerCase()]; +export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolean): { label: string; value: string }[] { + const unit: MeasurementUnit = getMeasurementUnit(metricUnit); const options = []; for (const [key, value] of Object.entries(MEASUREMENT_UNITS)) { if (!unit || value.kind === unit.kind) { - if (!showLongLabel || value.kind === UNITS_KIND.COUNT) { - options.push({ value: value.label, label: value.label }); + if (value.kind === UNITS_KIND.COUNT) { + if (showLongLabel) + // used by designer + options.push({ value: value.label, label: value.label }); + else { + value.altLabels?.forEach(altLabel => { + options.push({ value: altLabel, label: altLabel }); + }); + } } else { - options.push({ value: value.label, label: value.label + ' (' + value.longLabelPlural + ')' }); + options.push({ + value: value.label, + label: value.label + (showLongLabel ? ' (' + value.longLabelPlural + ')' : ''), + }); } } } return options; } +export function getMetricUnitOptionsFromKind( + unitKind?: UNITS_KIND, + showLongLabel?: boolean +): { label: string; value: string }[] { + let metricUnit: string = null; + switch (unitKind) { + case UNITS_KIND.COUNT: + metricUnit = 'unit'; + break; + case UNITS_KIND.MASS: + metricUnit = 'g'; + break; + case UNITS_KIND.VOLUME: + metricUnit = 'mL'; + break; + } + return getMetricUnitOptions(metricUnit, showLongLabel); +} + export function getAltUnitKeys(unitTypeStr): string[] { - const unit: MeasurementUnit = MEASUREMENT_UNITS[unitTypeStr?.toLowerCase()]; + const unit: MeasurementUnit = getMeasurementUnit(unitTypeStr); const options = []; Object.values(MEASUREMENT_UNITS).forEach(value => { if (!unit || value.kind === unit.kind) { - options.push(value.label); + if (value.altLabels) { + options.push(...value.altLabels); + } else options.push(value.label); } }); @@ -223,10 +294,10 @@ export function getVolumeMinStep(sampleTypeUnit?: MeasurementUnit | string) { return step; } - const unit = typeof sampleTypeUnit === 'string' ? MEASUREMENT_UNITS[sampleTypeUnit.toLowerCase()] : sampleTypeUnit; + const unit = typeof sampleTypeUnit === 'string' ? getMeasurementUnit(sampleTypeUnit) : sampleTypeUnit; // If we don't know the units, or it is 'unit' then use the default - if (!unit || unit === MEASUREMENT_UNITS.unit) { + if (!unit || unit.baseUnit === MEASUREMENT_UNITS.unit.baseUnit) { return step; } diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index 20b95f74e5..cb425a5351 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -31,10 +31,12 @@ import { getCommonDataValues, getDataStyling, getIconFontCls, + getInvalidSampleAmountMessage, getUpdatedData, getValueFromRow, getValuesSummary, hasAmountOrUnitChanged, + isAllowedSampleAmount, isBlankValue, isBoolean, isImage, @@ -1178,6 +1180,115 @@ describe('isIntegerInRange', () => { }); }); +describe('isAllowedSampleAmount', () => { + it('returns true for null or undefined', () => { + expect(isAllowedSampleAmount(null)).toBe(true); + expect(isAllowedSampleAmount(undefined)).toBe(true); + expect(isAllowedSampleAmount('')).toBe(true); + }); + + it('returns true for valid in range numeric values', () => { + expect(isAllowedSampleAmount(0)).toBe(true); + expect(isAllowedSampleAmount(123)).toBe(true); + expect(isAllowedSampleAmount(123.45)).toBe(true); + expect(isAllowedSampleAmount(1.1e-100)).toBe(true); + expect(isAllowedSampleAmount(1.1e100)).toBe(true); + }); + + it('returns true for valid numeric strings', () => { + expect(isAllowedSampleAmount('0')).toBe(true); + expect(isAllowedSampleAmount('123')).toBe(true); + expect(isAllowedSampleAmount('123.45')).toBe(true); + expect(isAllowedSampleAmount('1.1E-100')).toBe(true); + expect(isAllowedSampleAmount('1.1E100')).toBe(true); + expect(isAllowedSampleAmount(1.7e308)).toBe(true); + }); + + it('returns false for non-numeric values', () => { + expect(isAllowedSampleAmount({})).toBe(false); + expect(isAllowedSampleAmount([])).toBe(false); + expect(isAllowedSampleAmount('abc')).toBe(false); + expect(isAllowedSampleAmount('123abc')).toBe(false); + }); + + it('returns false for negative values', () => { + expect(isAllowedSampleAmount(-1)).toBe(false); + expect(isAllowedSampleAmount('-1')).toBe(false); + expect(isAllowedSampleAmount(-0.0001)).toBe(false); + expect(isAllowedSampleAmount('-0.0001')).toBe(false); + expect(isAllowedSampleAmount(-1.1e-100)).toBe(false); + expect(isAllowedSampleAmount(-1.1e100)).toBe(false); + expect(isAllowedSampleAmount('-1.1E-100')).toBe(false); + expect(isAllowedSampleAmount('-1.1E100')).toBe(false); + }); + + it('returns false for infinite number', () => { + expect(isAllowedSampleAmount(Infinity)).toBe(false); + expect(isAllowedSampleAmount(-Infinity)).toBe(false); + expect(isAllowedSampleAmount('Infinity')).toBe(false); + expect(isAllowedSampleAmount('-Infinity')).toBe(false); + expect(isAllowedSampleAmount(1.8e308)).toBe(false); + }); +}); + +describe('getInvalidSampleAmountMessage', () => { + it('returns undefined for null or undefined', () => { + expect(getInvalidSampleAmountMessage(null)).toBeNull(); + expect(getInvalidSampleAmountMessage(undefined)).toBeNull(); + expect(getInvalidSampleAmountMessage('')).toBeNull(); + }); + + it('returns undefined for valid in range numeric values', () => { + expect(getInvalidSampleAmountMessage(0)).toBeNull(); + expect(getInvalidSampleAmountMessage(123)).toBeNull(); + expect(getInvalidSampleAmountMessage(123.45)).toBeNull(); + expect(getInvalidSampleAmountMessage(1.1e-100)).toBeNull(); + expect(getInvalidSampleAmountMessage(1.1e100)).toBeNull(); + }); + + it('returns undefined for valid numeric strings', () => { + expect(getInvalidSampleAmountMessage('0')).toBeNull(); + expect(getInvalidSampleAmountMessage('123')).toBeNull(); + expect(getInvalidSampleAmountMessage('123.45')).toBeNull(); + expect(getInvalidSampleAmountMessage('1.1E-100')).toBeNull(); + expect(getInvalidSampleAmountMessage('1.1E100')).toBeNull(); + expect(getInvalidSampleAmountMessage(1.7e308)).toBeNull(); + }); + + it('returns error message for non-numeric values', () => { + expect(getInvalidSampleAmountMessage({})).toBe('Please enter a valid numeric value for amount.'); + expect(getInvalidSampleAmountMessage([])).toBe('Please enter a valid numeric value for amount.'); + expect(getInvalidSampleAmountMessage('abc')).toBe('Please enter a valid numeric value for amount.'); + expect(getInvalidSampleAmountMessage('123abc')).toBe('Please enter a valid numeric value for amount.'); + }); + + it('returns error message for negative values', () => { + expect(getInvalidSampleAmountMessage(-1)).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage('-1')).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage(-0.0001)).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage('-0.0001')).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage(-1.1e-100)).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage(-1.1e100)).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage('-1.1E-100')).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage('-1.1E100')).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage(-Infinity)).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage('-Infinity')).toBe('Amount must be a non-negative value.'); + expect(getInvalidSampleAmountMessage(-1.8e308)).toBe('Amount must be a non-negative value.'); + }); + + it('returns error message for infinite number', () => { + expect(getInvalidSampleAmountMessage(Infinity)).toBe( + 'Infinite or extremely large values are not allowed for amount.' + ); + expect(getInvalidSampleAmountMessage('Infinity')).toBe( + 'Infinite or extremely large values are not allowed for amount.' + ); + expect(getInvalidSampleAmountMessage(1.8e308)).toBe( + 'Infinite or extremely large values are not allowed for amount.' + ); + }); +}); + describe('isImage', () => { test('default', () => { expect(isImage('test')).toBeFalsy(); diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index 4aecd24ddb..b30e0fa5cb 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -447,6 +447,25 @@ export function isNonNegativeFloat(value: number | string): boolean { return isFloat(value) && Number(value) >= 0; } +export function isAllowedSampleAmount(value: unknown): boolean { + if (!value) return true; + + if (typeof value === 'number' || typeof value === 'string') + return isNonNegativeFloat(value) && Number.isFinite(Number(value)); + + return false; +} + +export const getInvalidSampleAmountMessage = (v: any): any => { + if (isAllowedSampleAmount(v)) return null; + + if (!isFloat(v)) return 'Please enter a valid numeric value for amount.'; + if (!isNonNegativeFloat(v)) return 'Amount must be a non-negative value.'; + else if (!Number.isFinite(Number(v))) return 'Infinite or extremely large values are not allowed for amount.'; + + return null; +}; + // works with string that might contain Scientific Notation export function parseScientificInt(value: any): number { if (value == null) return undefined;