From 1b6af4cea871246b5439c61ba1f744bfe434c698 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 26 Nov 2025 19:49:25 -0800 Subject: [PATCH 01/23] Support additional unit types --- packages/components/package-lock.json | 4 +- packages/components/package.json | 2 +- .../components/releaseNotes/components.md | 5 + .../samples/SampleTypePropertiesPanel.tsx | 144 +++++++++++++----- .../domainproperties/samples/models.ts | 3 +- .../components/samples/StorageAmountInput.tsx | 4 +- .../src/internal/util/measurement.test.ts | 42 +++-- .../src/internal/util/measurement.ts | 96 ++++++++++-- 8 files changed, 241 insertions(+), 59 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index abd1d95bd3..674d7758db 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.72.1", + "version": "6.72.2-fb-unitTypes.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.72.1", + "version": "6.72.2-fb-unitTypes.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 d8a9257bee..b97f8c1596 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "6.72.1", + "version": "6.72.2-fb-unitTypes.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 f66a935313..9b28b614e5 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,6 +1,11 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages +### version 6.X +*Released*: X 2025 +- Sample Amount/Units: support additional unit types + - TODO + ### version 6.72.1 *Released*: 25 November 2025 - QueryColumn to only apply displayWidth for multiLine columns diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index 303e7c631b..c5e0e14ea6 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -49,6 +49,12 @@ 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'; const PROPERTIES_HEADER_ID = 'sample-type-properties-hdr'; const ALIQUOT_HELP_LINK = getHelpLink('aliquotIDs'); @@ -67,6 +73,22 @@ const AddEntityHelpTip: FC<{ parentageLabel?: string }> = memo(({ parentageLabel ); }); + +const UnitKinds = { + [UNITS_KIND.NONE]: { + value: UNITS_KIND.NONE, label: 'Any', hideSubSelect: true, msg: 'Amounts can be entered in any unit and won’t be converted.' + }, + [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 unit, pcs, pack, blocks, slides, cells, box, kit, tests, or bottle and won’t be converted.' + }, +} + AddEntityHelpTip.displayName = 'AddEntityHelpTip'; const AutoLinkDataToStudyHelpTip: FC = () => ( @@ -153,6 +175,8 @@ interface State { loadingError: string; prefix: string; sampleTypeCategory: string; + metricUnitKind: { value: string; label: string, hideSubSelect?: boolean, msg?: string }; + validMetricUnitOptions: { value: string; label: string, hideSubSelect?: boolean, msg?: string }[]; } type Props = OwnProps & EntityProps & BasePropertiesPanelProps; @@ -186,10 +210,12 @@ class SampleTypePropertiesPanelImpl extends PureComponent => { - const { api, model } = this.props; + const { api, model, metricUnitProps } = this.props; try { const result = await api.query.selectRows({ @@ -198,7 +224,12 @@ class SampleTypePropertiesPanelImpl extends PureComponent { + this.updateValidStatus(this.props.model.set('metricUnit', '') as SampleTypeModel); + const unitKind = value ? UnitKinds[value] : null; + const unitOptions = getMetricUnitOptionsFromKind(unitKind?.value, true); + this.setState({ metricUnitKind: unitKind, validMetricUnitOptions: unitOptions }); + }; + + 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 +329,7 @@ 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} + placeholder="Select a type..." + value={metricUnitKind} + disabled={metricUnitProps?.lockUnitKind} /> - ) : ( - ) => { - this.onFieldChange(e.target.name, e.target.value); - }} - /> - )} +
-
+ {!metricUnitKind?.hideSubSelect && ( +
+
+ +
+
+ { + this.onUnitChange( + name, + formValue === undefined && option ? option.id : formValue + ); + }} + placeholder="Select a unit..." + value={model.metricUnit} + /> +
+
+ )} + {metricUnitKind?.msg && ( +
+
+
+ {metricUnitKind.msg} +
+
+ )} + )} )} diff --git a/packages/components/src/internal/components/domainproperties/samples/models.ts b/packages/components/src/internal/components/domainproperties/samples/models.ts index 67e27c1fbc..f49d082d54 100644 --- a/packages/components/src/internal/components/domainproperties/samples/models.ts +++ b/packages/components/src/internal/components/domainproperties/samples/models.ts @@ -108,8 +108,9 @@ export interface MetricUnitProps { includeMetricUnitProperty?: boolean; metricUnitHelpMsg?: string; metricUnitLabel?: string; - metricUnitOptions?: any[]; + metricUnitOptions?: { value: string; label: string }[]; metricUnitRequired?: boolean; + lockUnitKind?: boolean; } export interface AliquotNamePatternProps { diff --git a/packages/components/src/internal/components/samples/StorageAmountInput.tsx b/packages/components/src/internal/components/samples/StorageAmountInput.tsx index 6aaf98fe98..c14b3e8969 100644 --- a/packages/components/src/internal/components/samples/StorageAmountInput.tsx +++ b/packages/components/src/internal/components/samples/StorageAmountInput.tsx @@ -4,10 +4,10 @@ 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'; @@ -42,7 +42,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 = ( { test('constructor and operators', () => { @@ -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(10); + expect(getMetricUnitOptions('').length).toBe(10); + expect(getMetricUnitOptions('bad').length).toBe(10); }); test('getAltUnitKeys', () => { @@ -137,17 +144,34 @@ 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', 'pg']; expect(getAltUnitKeys('g')).toEqual(expectedGOptions); expect(getAltUnitKeys('kg')).toEqual(expectedGOptions); - expect(getAltUnitKeys('unit')).toEqual(['unit']); + expect(getAltUnitKeys('unit')).toEqual(['unit', 'blocks', 'bottle', 'box', 'cells', 'kit', 'pack', 'pcs', 'slides', 'tests']); // 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(19); + expect(getAltUnitKeys('').length).toBe(19); + expect(getAltUnitKeys('bad').length).toBe(19); }); + + describe('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('pcs'); + expect(unit).toEqual({ + ...MEASUREMENT_UNITS.unit, + label: 'pcs', + longLabelSingular: 'pcs', + longLabelPlural: 'pcs', + }); + }); + + }); describe('areUnitsCompatible', () => { diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index dbba81dca2..ab4712b804 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -4,6 +4,7 @@ export enum UNITS_KIND { COUNT = 'Count', MASS = 'Mass', VOLUME = 'Volume', + NONE = 'None' } export class UnitModel { @@ -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 + '"'); } @@ -96,6 +97,7 @@ export interface MeasurementUnit { longLabelPlural: string; longLabelSingular: string; ratio: number; + altLabels?: string[]; } export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { @@ -126,6 +128,33 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { ratio: 1000, displayPrecision: 12, // enable smallest precision of ng }, + ug: { + baseUnit: 'g', + label: 'ug', + longLabelSingular: 'microgram', + longLabelPlural: 'micrograms', + kind: UNITS_KIND.MASS, + ratio: 0.000001, + displayPrecision: 3, // enable smallest precision of ng + }, + ng: { + baseUnit: 'g', + label: 'ng', + longLabelSingular: 'nanogram', + longLabelPlural: 'nanograms', + kind: UNITS_KIND.MASS, + ratio: 0.000000001, + displayPrecision: 3, + }, + pg: { + baseUnit: 'g', + label: 'pg', + longLabelSingular: 'picogram', + longLabelPlural: 'picograms', + kind: UNITS_KIND.MASS, + ratio: 0.000000000001, + displayPrecision: 3, + }, ml: { baseUnit: 'mL', label: 'mL', @@ -161,9 +190,31 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { kind: UNITS_KIND.COUNT, ratio: 1, displayPrecision: 2, + altLabels: ['blocks', 'bottle', 'box', 'cells', 'kit', 'pack', 'pcs', 'slides', 'tests'] }, }; +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,16 +232,16 @@ 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; } -export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolean): any[] { - const unit: MeasurementUnit = MEASUREMENT_UNITS[metricUnit?.toLowerCase()]; +export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolean): { value: string, label: string }[] { + const unit: MeasurementUnit = getMeasurementUnit(metricUnit); const options = []; for (const [key, value] of Object.entries(MEASUREMENT_UNITS)) { @@ -200,17 +251,42 @@ export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolea } else { options.push({ value: value.label, label: value.label + ' (' + value.longLabelPlural + ')' }); } + + if (unit && value.kind === unit.kind) { + unit.altLabels?.forEach(altLabel => { + options.push({ value: altLabel, label: altLabel }); + }) + } } } return options; } +export function getMetricUnitOptionsFromKind(unitKind?: UNITS_KIND, showLongLabel?: boolean): { value: string, label: string }[] { + let metricUnit: string = null; + switch (unitKind){ + case UNITS_KIND.MASS: + metricUnit = 'g'; + break; + case UNITS_KIND.VOLUME: + metricUnit = 'mL'; + break; + case UNITS_KIND.COUNT: + metricUnit = 'unit'; + 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); + } } }); @@ -223,10 +299,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; } From e425374fe211d18d6001a375f71894632e544870 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 1 Dec 2025 19:12:30 -0800 Subject: [PATCH 02/23] Validate amount range on bulk/detail form. Show delta warning for empty initial amount --- .../components/forms/formsy/formsyRules.ts | 2 + .../forms/input/AmountUnitInput.tsx | 111 ++++++++++-------- .../components/forms/input/TextInput.tsx | 7 +- .../samples/SampleAmountEditModal.test.tsx | 2 + .../samples/SampleAmountEditModal.tsx | 4 +- .../components/samples/StorageAmountInput.tsx | 30 ++--- .../src/internal/util/measurement.ts | 10 +- .../src/internal/util/utils.test.ts | 105 +++++++++++++++++ .../components/src/internal/util/utils.ts | 23 ++++ 9 files changed, 220 insertions(+), 74 deletions(-) diff --git a/packages/components/src/internal/components/forms/formsy/formsyRules.ts b/packages/components/src/internal/components/forms/formsy/formsyRules.ts index 75ca2a3567..8408f316aa 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..78ed282e9a 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, isValidSampleAmountWithError } 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,70 @@ export const AmountUnitInput: FC = memo(props => { }); }, [setDisabled]); + const onAmountChange = useCallback((name: string, value: any) => { + const errorMsg = isValidSampleAmountWithError(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.tsx b/packages/components/src/internal/components/samples/StorageAmountInput.tsx index c14b3e8969..084f693660 100644 --- a/packages/components/src/internal/components/samples/StorageAmountInput.tsx +++ b/packages/components/src/internal/components/samples/StorageAmountInput.tsx @@ -1,4 +1,4 @@ -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'; @@ -6,16 +6,10 @@ import { LabelHelpTip } from '../base/LabelHelpTip'; import { getMeasurementUnit, getMetricUnitOptions, - getVolumeMinStep, isMeasurementUnitIgnoreCase, UnitModel, } from '../../util/measurement'; - -const negativeValueMessage = ( - - Amount must be a non-negative value. - -); +import { isValidSampleAmountWithError } 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 ? model?.value + '' : ''); + const unitText = model?.unit?.label || model.unitStr; let preferredUnitMessage; @@ -82,7 +77,12 @@ export const StorageAmountInput: FC = 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 +98,18 @@ export const StorageAmountInput: FC = memo(props => { {unitDisplay} {preferredUnitMessage}
- {isNegativeValue ? negativeValueMessage : undefined} + + {isValidSampleAmountWithError(amountInput)} + ); }); diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index ab4712b804..96690c9025 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -108,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 }, mg: { baseUnit: 'g', @@ -117,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', @@ -126,7 +126,7 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { longLabelPlural: 'kilograms', kind: UNITS_KIND.MASS, ratio: 1000, - displayPrecision: 12, // enable smallest precision of ng + displayPrecision: 15, // enable smallest precision of pg }, ug: { baseUnit: 'g', @@ -135,7 +135,7 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { longLabelPlural: 'micrograms', kind: UNITS_KIND.MASS, ratio: 0.000001, - displayPrecision: 3, // enable smallest precision of ng + displayPrecision: 6, // enable smallest precision of pg }, ng: { baseUnit: 'g', @@ -153,7 +153,7 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { longLabelPlural: 'picograms', kind: UNITS_KIND.MASS, ratio: 0.000000000001, - displayPrecision: 3, + displayPrecision: 0, }, ml: { baseUnit: 'mL', diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index 20b95f74e5..f47fcbc771 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -35,6 +35,7 @@ import { getValueFromRow, getValuesSummary, hasAmountOrUnitChanged, + isAllowedSampleAmount, isBlankValue, isBoolean, isImage, @@ -45,6 +46,7 @@ import { isQuotedWithDelimiters, isSameWithStringCompare, isSetEqual, + isValidSampleAmountWithError, makeCommaSeparatedString, parseCsvString, parseScientificInt, @@ -1178,6 +1180,109 @@ 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('isValidSampleAmountWithError', () => { + it('returns undefined for null or undefined', () => { + expect(isValidSampleAmountWithError(null)).toBeNull(); + expect(isValidSampleAmountWithError(undefined)).toBeNull(); + expect(isValidSampleAmountWithError('')).toBeNull(); + }); + + it('returns undefined for valid in range numeric values', () => { + expect(isValidSampleAmountWithError(0)).toBeNull(); + expect(isValidSampleAmountWithError(123)).toBeNull(); + expect(isValidSampleAmountWithError(123.45)).toBeNull(); + expect(isValidSampleAmountWithError(1.1E-100)).toBeNull(); + expect(isValidSampleAmountWithError(1.1E100)).toBeNull(); + }); + + it('returns undefined for valid numeric strings', () => { + expect(isValidSampleAmountWithError('0')).toBeNull(); + expect(isValidSampleAmountWithError('123')).toBeNull(); + expect(isValidSampleAmountWithError('123.45')).toBeNull(); + expect(isValidSampleAmountWithError('1.1E-100')).toBeNull(); + expect(isValidSampleAmountWithError('1.1E100')).toBeNull(); + expect(isValidSampleAmountWithError(1.7E308)).toBeNull(); + }); + + it('returns error message for non-numeric values', () => { + expect(isValidSampleAmountWithError({})).toBe('Please enter a valid numeric value for amount.') + expect(isValidSampleAmountWithError([])).toBe('Please enter a valid numeric value for amount.') + expect(isValidSampleAmountWithError('abc')).toBe('Please enter a valid numeric value for amount.') + expect(isValidSampleAmountWithError('123abc')).toBe('Please enter a valid numeric value for amount.') + }); + + it('returns error message for negative values', () => { + expect(isValidSampleAmountWithError(-1)).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError('-1')).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError(-0.0001)).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError('-0.0001')).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError(-1.1E-100)).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError(-1.1E100)).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError('-1.1E-100')).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError('-1.1E100')).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError(-Infinity)).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError('-Infinity')).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError(-1.8E308)).toBe('Amount must be a non-negative value.'); + }); + + it('returns error message for infinite number', () => { + expect(isValidSampleAmountWithError(Infinity)).toBe('Infinite or extremely large values are not allowed for amount.') + expect(isValidSampleAmountWithError('Infinity')).toBe('Infinite or extremely large values are not allowed for amount.'); + expect(isValidSampleAmountWithError(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..2a321b005b 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -447,6 +447,29 @@ 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 isValidSampleAmountWithError = (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; From 1fee67d522750ae58cfad3148315c2cbf63e82e9 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 1 Dec 2025 19:37:09 -0800 Subject: [PATCH 03/23] merge from develop --- packages/components/package-lock.json | 4 +- .../samples/SampleTypePropertiesPanel.tsx | 127 ++++++++++-------- .../domainproperties/samples/models.ts | 5 +- .../components/forms/formsy/formsyRules.ts | 2 +- .../forms/input/AmountUnitInput.tsx | 10 +- .../components/samples/StorageAmountInput.tsx | 13 +- .../src/internal/util/measurement.test.ts | 17 ++- .../src/internal/util/measurement.ts | 31 ++--- .../src/internal/util/utils.test.ts | 56 ++++---- .../components/src/internal/util/utils.ts | 14 +- 10 files changed, 154 insertions(+), 125 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 23e14067d2..57faee27b5 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.0.0", + "version": "7.0.1-fb-unitTypes.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.0.0", + "version": "7.0.1-fb-unitTypes.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index c5e0e14ea6..e5e44c9f39 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -53,7 +53,7 @@ import { getMeasurementUnit, getMetricUnitOptions, getMetricUnitOptionsFromKind, - UNITS_KIND + UNITS_KIND, } from '../../../util/measurement'; const PROPERTIES_HEADER_ID = 'sample-type-properties-hdr'; @@ -76,18 +76,26 @@ const AddEntityHelpTip: FC<{ parentageLabel?: string }> = memo(({ parentageLabel const UnitKinds = { [UNITS_KIND.NONE]: { - value: UNITS_KIND.NONE, label: 'Any', hideSubSelect: true, msg: 'Amounts can be entered in any unit and won’t be converted.' + value: UNITS_KIND.NONE, + label: 'Any', + hideSubSelect: true, + msg: 'Amounts can be entered in any unit and won’t be converted.', }, [UNITS_KIND.MASS]: { - value: UNITS_KIND.MASS, label: 'Mass' + value: UNITS_KIND.MASS, + label: 'Mass', }, [UNITS_KIND.VOLUME]: { - value: UNITS_KIND.VOLUME, label: 'Volume' + value: UNITS_KIND.VOLUME, + label: 'Volume', }, [UNITS_KIND.COUNT]: { - value: UNITS_KIND.COUNT, label: 'Other', hideSubSelect: true, msg: 'Amounts can be entered as unit, pcs, pack, blocks, slides, cells, box, kit, tests, or bottle and won’t be converted.' + value: UNITS_KIND.COUNT, + label: 'Other', + hideSubSelect: true, + msg: 'Amounts can be entered as unit, pcs, pack, blocks, slides, cells, box, kit, tests, or bottle and won’t be converted.', }, -} +}; AddEntityHelpTip.displayName = 'AddEntityHelpTip'; @@ -173,21 +181,21 @@ interface State { containers: Container[]; isValid: boolean; loadingError: string; + metricUnitKind: { hideSubSelect?: boolean; label: string; msg?: string; value: string }; prefix: string; sampleTypeCategory: string; - metricUnitKind: { value: string; label: string, hideSubSelect?: boolean, msg?: string }; - validMetricUnitOptions: { value: string; label: string, hideSubSelect?: boolean, msg?: string }[]; + validMetricUnitOptions: { hideSubSelect?: boolean; label: string; msg?: string; value: string }[]; } -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, @@ -228,7 +236,7 @@ class SampleTypePropertiesPanelImpl extends PureComponent
@@ -388,25 +404,24 @@ class SampleTypePropertiesPanelImpl extends PureComponent {appPropertiesOnly && } {showAliquotNameExpression && (

Pattern used for generating unique Ids for Aliquots.

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

More info

} + label="Aliquot Naming Pattern" />
@@ -444,11 +460,11 @@ class SampleTypePropertiesPanelImpl extends PureComponent) => { this.onFieldChange(e.target.name, e.target.value); }} + placeholder={aliquotNameExpressionPlaceholder ?? ALIQUOT_NAME_PLACEHOLDER} + type="text" value={model.aliquotNameExpression} />
@@ -456,29 +472,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 /> )} @@ -487,15 +503,15 @@ class SampleTypePropertiesPanelImpl extends PureComponent
} + label="Auto-Link Data to Study" />
@@ -503,17 +519,17 @@ class SampleTypePropertiesPanelImpl extends PureComponent
} + label="Linked Dataset Category" />
@@ -529,16 +545,16 @@ class SampleTypePropertiesPanelImpl extends PureComponent
@@ -546,28 +562,25 @@ class SampleTypePropertiesPanelImpl extends PureComponent
- +
{ this.onMetricUnitKindChange( name, formValue === undefined && option ? option.id : formValue ); }} + options={Object.values(UnitKinds)} placeholder="Select a type..." + required={metricUnitRequired} value={metricUnitKind} - disabled={metricUnitProps?.lockUnitKind} />
@@ -575,26 +588,26 @@ class SampleTypePropertiesPanelImpl extends PureComponent
{ this.onUnitChange( name, formValue === undefined && option ? option.id : formValue ); }} + options={validMetricUnitOptions} placeholder="Select a unit..." + required={metricUnitRequired} value={model.metricUnit} />
@@ -615,10 +628,10 @@ class SampleTypePropertiesPanelImpl extends PureComponent
- } /> + } 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 f49d082d54..a482785208 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({ @@ -106,11 +105,11 @@ export class SampleTypeModel extends Record({ export interface MetricUnitProps { includeMetricUnitProperty?: boolean; + lockUnitKind?: boolean; metricUnitHelpMsg?: string; metricUnitLabel?: string; - metricUnitOptions?: { value: string; label: string }[]; + metricUnitOptions?: { label: string; value: string }[]; metricUnitRequired?: boolean; - lockUnitKind?: boolean; } export interface AliquotNamePatternProps { diff --git a/packages/components/src/internal/components/forms/formsy/formsyRules.ts b/packages/components/src/internal/components/forms/formsy/formsyRules.ts index 8408f316aa..f745027461 100644 --- a/packages/components/src/internal/components/forms/formsy/formsyRules.ts +++ b/packages/components/src/internal/components/forms/formsy/formsyRules.ts @@ -62,7 +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) , + 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 78ed282e9a..2ff70f9804 100644 --- a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx +++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx @@ -76,17 +76,19 @@ export const AmountUnitInput: FC = memo(props => { = memo(props => { } } - const onChange = useCallback(event => { - const newValue = event?.target?.value; - amountChangedHandler(newValue); - setAmountInput(newValue); - }, [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 ( diff --git a/packages/components/src/internal/util/measurement.test.ts b/packages/components/src/internal/util/measurement.test.ts index ecfae35097..a7f309347d 100644 --- a/packages/components/src/internal/util/measurement.test.ts +++ b/packages/components/src/internal/util/measurement.test.ts @@ -4,7 +4,7 @@ import { getMeasurementUnit, getMetricUnitOptions, MEASUREMENT_UNITS, - UnitModel + UnitModel, } from './measurement'; describe('UnitModel', () => { @@ -148,7 +148,18 @@ describe('MetricUnit utils', () => { expect(getAltUnitKeys('g')).toEqual(expectedGOptions); expect(getAltUnitKeys('kg')).toEqual(expectedGOptions); - expect(getAltUnitKeys('unit')).toEqual(['unit', 'blocks', 'bottle', 'box', 'cells', 'kit', 'pack', 'pcs', 'slides', 'tests']); + expect(getAltUnitKeys('unit')).toEqual([ + 'unit', + 'blocks', + 'bottle', + 'box', + 'cells', + 'kit', + 'pack', + 'pcs', + 'slides', + 'tests', + ]); // include all options when no unitTypeStr or an invalid unitTypeStr is provided expect(getAltUnitKeys(null).length).toBe(19); @@ -170,8 +181,6 @@ describe('MetricUnit utils', () => { longLabelPlural: 'pcs', }); }); - - }); describe('areUnitsCompatible', () => { diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index 96690c9025..da01241ef2 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -3,8 +3,8 @@ import { immerable } from 'immer'; export enum UNITS_KIND { COUNT = 'Count', MASS = 'Mass', + NONE = 'None', VOLUME = 'Volume', - NONE = 'None' } export class UnitModel { @@ -89,6 +89,7 @@ export class UnitModel { } export interface MeasurementUnit { + altLabels?: string[]; baseUnit: string; // Number of decimal places allowed when unit is displayed displayPrecision: number; @@ -97,10 +98,9 @@ export interface MeasurementUnit { longLabelPlural: string; longLabelSingular: string; ratio: number; - altLabels?: string[]; } -export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { +export const MEASUREMENT_UNITS: Record = { g: { label: 'g', baseUnit: 'g', @@ -190,17 +190,15 @@ export const MEASUREMENT_UNITS: { [key: string]: MeasurementUnit } = { kind: UNITS_KIND.COUNT, ratio: 1, displayPrecision: 2, - altLabels: ['blocks', 'bottle', 'box', 'cells', 'kit', 'pack', 'pcs', 'slides', 'tests'] + altLabels: ['blocks', 'bottle', 'box', 'cells', 'kit', 'pack', 'pcs', 'slides', 'tests'], }, }; export function getMeasurementUnit(unitStr: string): MeasurementUnit { - if (!unitStr) - return null; + if (!unitStr) return null; const unit = MEASUREMENT_UNITS[unitStr?.toLowerCase()]; - if (unit) - return unit; + if (unit) return unit; const unitStrLc = unitStr.toLowerCase(); if (MEASUREMENT_UNITS.unit.altLabels.indexOf(unitStrLc) > -1) { @@ -240,7 +238,7 @@ export function areUnitsCompatible(unitAStr: string, unitBStr: string) { return unitA.kind == unitB.kind; } -export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolean): { value: string, label: string }[] { +export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolean): { label: string; value: string }[] { const unit: MeasurementUnit = getMeasurementUnit(metricUnit); const options = []; @@ -255,25 +253,28 @@ export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolea if (unit && value.kind === unit.kind) { unit.altLabels?.forEach(altLabel => { options.push({ value: altLabel, label: altLabel }); - }) + }); } } } return options; } -export function getMetricUnitOptionsFromKind(unitKind?: UNITS_KIND, showLongLabel?: boolean): { value: string, label: string }[] { +export function getMetricUnitOptionsFromKind( + unitKind?: UNITS_KIND, + showLongLabel?: boolean +): { label: string; value: string }[] { let metricUnit: string = null; - switch (unitKind){ + switch (unitKind) { + case UNITS_KIND.COUNT: + metricUnit = 'unit'; + break; case UNITS_KIND.MASS: metricUnit = 'g'; break; case UNITS_KIND.VOLUME: metricUnit = 'mL'; break; - case UNITS_KIND.COUNT: - metricUnit = 'unit'; - break; } return getMetricUnitOptions(metricUnit, showLongLabel); } diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index f47fcbc771..d99a0a9720 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -1191,8 +1191,8 @@ describe('isAllowedSampleAmount', () => { 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.1e-100)).toBe(true); + expect(isAllowedSampleAmount(1.1e100)).toBe(true); }); it('returns true for valid numeric strings', () => { @@ -1201,7 +1201,7 @@ describe('isAllowedSampleAmount', () => { 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); + expect(isAllowedSampleAmount(1.7e308)).toBe(true); }); it('returns false for non-numeric values', () => { @@ -1216,8 +1216,8 @@ describe('isAllowedSampleAmount', () => { 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); expect(isAllowedSampleAmount('-1.1E-100')).toBe(false); expect(isAllowedSampleAmount('-1.1E100')).toBe(false); }); @@ -1227,7 +1227,7 @@ describe('isAllowedSampleAmount', () => { expect(isAllowedSampleAmount(-Infinity)).toBe(false); expect(isAllowedSampleAmount('Infinity')).toBe(false); expect(isAllowedSampleAmount('-Infinity')).toBe(false); - expect(isAllowedSampleAmount(1.8E308)).toBe(false); + expect(isAllowedSampleAmount(1.8e308)).toBe(false); }); }); @@ -1242,8 +1242,8 @@ describe('isValidSampleAmountWithError', () => { expect(isValidSampleAmountWithError(0)).toBeNull(); expect(isValidSampleAmountWithError(123)).toBeNull(); expect(isValidSampleAmountWithError(123.45)).toBeNull(); - expect(isValidSampleAmountWithError(1.1E-100)).toBeNull(); - expect(isValidSampleAmountWithError(1.1E100)).toBeNull(); + expect(isValidSampleAmountWithError(1.1e-100)).toBeNull(); + expect(isValidSampleAmountWithError(1.1e100)).toBeNull(); }); it('returns undefined for valid numeric strings', () => { @@ -1252,34 +1252,40 @@ describe('isValidSampleAmountWithError', () => { expect(isValidSampleAmountWithError('123.45')).toBeNull(); expect(isValidSampleAmountWithError('1.1E-100')).toBeNull(); expect(isValidSampleAmountWithError('1.1E100')).toBeNull(); - expect(isValidSampleAmountWithError(1.7E308)).toBeNull(); + expect(isValidSampleAmountWithError(1.7e308)).toBeNull(); }); it('returns error message for non-numeric values', () => { - expect(isValidSampleAmountWithError({})).toBe('Please enter a valid numeric value for amount.') - expect(isValidSampleAmountWithError([])).toBe('Please enter a valid numeric value for amount.') - expect(isValidSampleAmountWithError('abc')).toBe('Please enter a valid numeric value for amount.') - expect(isValidSampleAmountWithError('123abc')).toBe('Please enter a valid numeric value for amount.') + expect(isValidSampleAmountWithError({})).toBe('Please enter a valid numeric value for amount.'); + expect(isValidSampleAmountWithError([])).toBe('Please enter a valid numeric value for amount.'); + expect(isValidSampleAmountWithError('abc')).toBe('Please enter a valid numeric value for amount.'); + expect(isValidSampleAmountWithError('123abc')).toBe('Please enter a valid numeric value for amount.'); }); it('returns error message for negative values', () => { - expect(isValidSampleAmountWithError(-1)).toBe('Amount must be a non-negative value.') - expect(isValidSampleAmountWithError('-1')).toBe('Amount must be a non-negative value.') - expect(isValidSampleAmountWithError(-0.0001)).toBe('Amount must be a non-negative value.') - expect(isValidSampleAmountWithError('-0.0001')).toBe('Amount must be a non-negative value.') - expect(isValidSampleAmountWithError(-1.1E-100)).toBe('Amount must be a non-negative value.') - expect(isValidSampleAmountWithError(-1.1E100)).toBe('Amount must be a non-negative value.') - expect(isValidSampleAmountWithError('-1.1E-100')).toBe('Amount must be a non-negative value.') - expect(isValidSampleAmountWithError('-1.1E100')).toBe('Amount must be a non-negative value.') + expect(isValidSampleAmountWithError(-1)).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError('-1')).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError(-0.0001)).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError('-0.0001')).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError(-1.1e-100)).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError(-1.1e100)).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError('-1.1E-100')).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError('-1.1E100')).toBe('Amount must be a non-negative value.'); expect(isValidSampleAmountWithError(-Infinity)).toBe('Amount must be a non-negative value.'); expect(isValidSampleAmountWithError('-Infinity')).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError(-1.8E308)).toBe('Amount must be a non-negative value.'); + expect(isValidSampleAmountWithError(-1.8e308)).toBe('Amount must be a non-negative value.'); }); it('returns error message for infinite number', () => { - expect(isValidSampleAmountWithError(Infinity)).toBe('Infinite or extremely large values are not allowed for amount.') - expect(isValidSampleAmountWithError('Infinity')).toBe('Infinite or extremely large values are not allowed for amount.'); - expect(isValidSampleAmountWithError(1.8E308)).toBe('Infinite or extremely large values are not allowed for amount.'); + expect(isValidSampleAmountWithError(Infinity)).toBe( + 'Infinite or extremely large values are not allowed for amount.' + ); + expect(isValidSampleAmountWithError('Infinity')).toBe( + 'Infinite or extremely large values are not allowed for amount.' + ); + expect(isValidSampleAmountWithError(1.8e308)).toBe( + 'Infinite or extremely large values are not allowed for amount.' + ); }); }); diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index 2a321b005b..df98195257 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -457,18 +457,14 @@ export function isAllowedSampleAmount(value: unknown): boolean { } export const isValidSampleAmountWithError = (v: any): any => { - if (isAllowedSampleAmount(v)) - return null; + 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.'; + 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 { From 4c2cec0a1cfc482b16330e6016337993dc1975f4 Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 2 Dec 2025 16:55:15 -0800 Subject: [PATCH 04/23] additional changes --- .../samples/SampleTypePropertiesPanel.tsx | 62 +++++++++++++++++-- .../src/internal/components/editable/utils.ts | 9 ++- .../components/samples/StorageAmountInput.tsx | 2 +- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index e5e44c9f39..c28820265d 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -55,6 +55,7 @@ import { 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'); @@ -74,7 +75,7 @@ const AddEntityHelpTip: FC<{ parentageLabel?: string }> = memo(({ parentageLabel ); }); -const UnitKinds = { +const UnitKinds : Record = { [UNITS_KIND.NONE]: { value: UNITS_KIND.NONE, label: 'Any', @@ -97,6 +98,20 @@ const UnitKinds = { }, }; +const getValidUnitKinds = (lockUnitKind?: boolean, metricUnit?: string) : UnitKindType[] => { + if (!lockUnitKind) + return Object.values(UnitKinds); + + const validOptions = [UnitKinds[UNITS_KIND.NONE]]; // any unit can switch to no unit type + + if (metricUnit) { + const unitKind = getMeasurementUnit(metricUnit)?.kind; + validOptions.push(UnitKinds[unitKind]); + } + + return validOptions; +} + AddEntityHelpTip.displayName = 'AddEntityHelpTip'; const AutoLinkDataToStudyHelpTip: FC = () => ( @@ -177,14 +192,21 @@ interface EntityProps { nounSingular?: string; } +interface UnitKindType { + hideSubSelect?: boolean; label: string; msg?: string; value: string +} + interface State { containers: Container[]; isValid: boolean; loadingError: string; - metricUnitKind: { hideSubSelect?: boolean; label: string; msg?: string; value: string }; + metricUnitKind: UnitKindType; + validUnitKinds: UnitKindType[]; prefix: string; sampleTypeCategory: string; validMetricUnitOptions: { hideSubSelect?: boolean; label: string; msg?: string; value: string }[]; + originalUnit: string; + unitChangeWarning: string; } type Props = BasePropertiesPanelProps & EntityProps & OwnProps; @@ -219,7 +241,10 @@ class SampleTypePropertiesPanelImpl extends PureComponent => { @@ -232,11 +257,16 @@ class SampleTypePropertiesPanelImpl extends PureComponent { - this.updateValidStatus(this.props.model.set('metricUnit', '') as SampleTypeModel); + const { originalUnit } = this.state; const unitKind = value ? UnitKinds[value] : null; const unitOptions = getMetricUnitOptionsFromKind(unitKind?.value, true); - this.setState({ metricUnitKind: unitKind, validMetricUnitOptions: unitOptions }); + let unitToSelect = value === UNITS_KIND.COUNT ? 'unit' : ''; + let unitChangeWarning = null; + if (originalUnit && value == UNITS_KIND.NONE) + unitChangeWarning = "Once switched to 'Any' amount type, you cannot 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 => { @@ -345,6 +384,8 @@ class SampleTypePropertiesPanelImpl extends PureComponent { @@ -577,7 +618,7 @@ class SampleTypePropertiesPanelImpl extends PureComponent )} + {unitChangeWarning && ( +
+
+
+ {unitChangeWarning} +
+
+ )} + )} diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts index b5c1ee6f13..98e459a562 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 { isBoolean, isFloat, isInteger, isValidSampleAmountWithError } from '../../util/utils'; import { incrementClientSideMetricCount } from '../../actions'; import { CellMessage } from './models'; @@ -64,8 +64,11 @@ 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 = isValidSampleAmountWithError(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/samples/StorageAmountInput.tsx b/packages/components/src/internal/components/samples/StorageAmountInput.tsx index 49f22c375b..ac709a5a97 100644 --- a/packages/components/src/internal/components/samples/StorageAmountInput.tsx +++ b/packages/components/src/internal/components/samples/StorageAmountInput.tsx @@ -26,7 +26,7 @@ export const StorageAmountInput: FC = memo(props => { const { className, model, preferredUnit, inputName, label, tipText, amountChangedHandler, unitsChangedHandler } = props; - const [amountInput, setAmountInput] = useState(model?.value ? model?.value + '' : ''); + const [amountInput, setAmountInput] = useState(model?.value?.toString() || ''); const unitText = model?.unit?.label || model.unitStr; let preferredUnitMessage; From 0b91b920aa9a64d7eabf7d24b8c3610bc65ff26d Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 2 Dec 2025 18:36:41 -0800 Subject: [PATCH 05/23] bug fixes and test updates --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- .../domainproperties/samples/SampleTypePropertiesPanel.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 57faee27b5..c3dd523171 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.1", + "version": "7.0.1-fb-unitTypes.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.1", + "version": "7.0.1-fb-unitTypes.4", "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 248fa6c645..71e142bc3b 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.1", + "version": "7.0.1-fb-unitTypes.4", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index c28820265d..d05c2d6e29 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -99,7 +99,7 @@ const UnitKinds : Record = { }; const getValidUnitKinds = (lockUnitKind?: boolean, metricUnit?: string) : UnitKindType[] => { - if (!lockUnitKind) + if (!lockUnitKind || !metricUnit) return Object.values(UnitKinds); const validOptions = [UnitKinds[UNITS_KIND.NONE]]; // any unit can switch to no unit type From 69e9349af358b6af7522b6b1bac91eda52e57deb Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 3 Dec 2025 12:26:35 -0800 Subject: [PATCH 06/23] fix test --- packages/components/src/internal/components/editable/utils.ts | 2 +- packages/components/src/internal/util/measurement.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts index 98e459a562..9ffc2b3e40 100644 --- a/packages/components/src/internal/components/editable/utils.ts +++ b/packages/components/src/internal/components/editable/utils.ts @@ -67,7 +67,7 @@ export const getValidatedEditableGridValue = (origValue: any, col: QueryColumn): } 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') + else if (col?.fieldKey?.toLowerCase() === 'storedamount') message = isValidSampleAmountWithError(value); } else if (jsonType === 'string' && scale) { if (value.toString().trim().length > scale) diff --git a/packages/components/src/internal/util/measurement.test.ts b/packages/components/src/internal/util/measurement.test.ts index a7f309347d..ca74335425 100644 --- a/packages/components/src/internal/util/measurement.test.ts +++ b/packages/components/src/internal/util/measurement.test.ts @@ -15,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'); From 65bbb5611f1c1972c1120e2a1c39b0c9a69cd601 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 3 Dec 2025 12:35:38 -0800 Subject: [PATCH 07/23] clean --- .../samples/SampleTypePropertiesPanel.tsx | 11 +++++------ .../components/src/internal/util/measurement.test.ts | 2 +- packages/components/src/internal/util/measurement.ts | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index d05c2d6e29..99f3def326 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -104,13 +104,12 @@ const getValidUnitKinds = (lockUnitKind?: boolean, metricUnit?: string) : UnitKi const validOptions = [UnitKinds[UNITS_KIND.NONE]]; // any unit can switch to no unit type - if (metricUnit) { - const unitKind = getMeasurementUnit(metricUnit)?.kind; + const unitKind = getMeasurementUnit(metricUnit)?.kind; + if (unitKind) validOptions.push(UnitKinds[unitKind]); - } return validOptions; -} +}; AddEntityHelpTip.displayName = 'AddEntityHelpTip'; @@ -323,10 +322,10 @@ class SampleTypePropertiesPanelImpl extends PureComponent { expect(getAltUnitKeys('bad').length).toBe(19); }); - describe('getMeasurementUnit', () => { + test('getMeasurementUnit', () => { expect(getMeasurementUnit(undefined)).toBeNull(); expect(getMeasurementUnit('')).toBeNull(); expect(getMeasurementUnit('invalidUnit')).toBeNull(); diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index da01241ef2..9de8219297 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -235,7 +235,7 @@ export function areUnitsCompatible(unitAStr: string, unitBStr: string) { if (!unitA || !unitB) { return false; } - return unitA.kind == unitB.kind; + return unitA.kind === unitB.kind; } export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolean): { label: string; value: string }[] { From 69d1fd4648d5977da98dc4d7f8ed369d3d272f4b Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 3 Dec 2025 13:43:58 -0800 Subject: [PATCH 08/23] clean --- packages/components/package-lock.json | 4 +-- packages/components/package.json | 2 +- .../components/releaseNotes/components.md | 7 ++-- .../SampleTypePropertiesPanel.test.tsx | 31 ++++++++++++++++- .../samples/SampleTypePropertiesPanel.tsx | 33 +++++++++++-------- .../src/internal/components/editable/utils.ts | 6 ++-- 6 files changed, 59 insertions(+), 24 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index c3dd523171..dd2e101852 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.4", + "version": "7.0.1-fb-unitTypes.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.4", + "version": "7.0.1-fb-unitTypes.5", "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 71e142bc3b..3e2e243523 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.4", + "version": "7.0.1-fb-unitTypes.5", "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 334aa3be18..cbed572597 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -3,8 +3,11 @@ Components, models, actions, and utility functions for LabKey applications and p ### version 6.X *Released*: X 2025 -- Sample Amount/Units: support additional unit types - - TODO +- 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, pg) 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.0.0 *Released*: 1 December 2025 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..1fae14d0bd 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 = { @@ -270,4 +271,32 @@ describe('SampleTypePropertiesPanel', () => { const aliquotField = fields[4]; expect(aliquotField.textContent).toEqual('Aliquot Naming Pattern'); }); + + test('getValidUnitKinds', () => { + it('returns all unit kinds when lockUnitKind and metricUnit are undefined', () => { + const result = getValidUnitKinds(); + expect(result).toEqual(Object.values(UnitKinds)); + }); + + it('returns all unit kinds when lockUnitKind is false', () => { + const result = getValidUnitKinds(false, 'mL'); + expect(result).toEqual(Object.values(UnitKinds)); + }); + + it('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]]); + }); + + it('returns only NONE when lockUnitKind is true and metricUnit is invalid', () => { + const result = getValidUnitKinds(true, 'invalidUnit'); + expect(result).toEqual([UnitKinds[UNITS_KIND.NONE]]); + }); + + it('returns only NONE when lockUnitKind is true and metricUnit is undefined', () => { + const result = getValidUnitKinds(true); + expect(result).toEqual([UnitKinds[UNITS_KIND.NONE]]); + }); + }); + }); diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index 99f3def326..d0f98fd2d9 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -75,7 +75,7 @@ const AddEntityHelpTip: FC<{ parentageLabel?: string }> = memo(({ parentageLabel ); }); -const UnitKinds : Record = { +export const UnitKinds: Record = { [UNITS_KIND.NONE]: { value: UNITS_KIND.NONE, label: 'Any', @@ -98,15 +98,13 @@ const UnitKinds : Record = { }, }; -const getValidUnitKinds = (lockUnitKind?: boolean, metricUnit?: string) : UnitKindType[] => { - if (!lockUnitKind || !metricUnit) - return Object.values(UnitKinds); +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]); + if (unitKind) validOptions.push(UnitKinds[unitKind]); return validOptions; }; @@ -192,7 +190,10 @@ interface EntityProps { } interface UnitKindType { - hideSubSelect?: boolean; label: string; msg?: string; value: string + hideSubSelect?: boolean; + label: string; + msg?: string; + value: string; } interface State { @@ -200,12 +201,12 @@ interface State { isValid: boolean; loadingError: string; metricUnitKind: UnitKindType; - validUnitKinds: UnitKindType[]; + originalUnit: string; prefix: string; sampleTypeCategory: string; - validMetricUnitOptions: { hideSubSelect?: boolean; label: string; msg?: string; value: string }[]; - originalUnit: string; unitChangeWarning: string; + validMetricUnitOptions: UnitKindType[]; + validUnitKinds: UnitKindType[]; } type Props = BasePropertiesPanelProps & EntityProps & OwnProps; @@ -257,7 +258,9 @@ class SampleTypePropertiesPanelImpl extends PureComponent
)} - )} diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts index 9ffc2b3e40..791129f113 100644 --- a/packages/components/src/internal/components/editable/utils.ts +++ b/packages/components/src/internal/components/editable/utils.ts @@ -65,10 +65,8 @@ export const getValidatedEditableGridValue = (origValue: any, col: QueryColumn): } else if (jsonType === 'float' && !isFloat(value)) { message = 'Invalid decimal'; } 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 = isValidSampleAmountWithError(value); + if (Number(value) < 0) message = col.caption + ' must be non-negative'; + else if (col?.fieldKey?.toLowerCase() === 'storedamount') message = isValidSampleAmountWithError(value); } else if (jsonType === 'string' && scale) { if (value.toString().trim().length > scale) message = value.toString().trim().length + '/' + scale + ' characters'; From b485a135a8a0e77c4256a3dbaf83414dc73a2385 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 3 Dec 2025 14:36:56 -0800 Subject: [PATCH 09/23] remove support for pg unit --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- packages/components/releaseNotes/components.md | 2 +- .../src/internal/util/measurement.test.ts | 2 +- .../components/src/internal/util/measurement.ts | 15 +++------------ 5 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index dd2e101852..9522d42b3b 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.5", + "version": "7.0.1-fb-unitTypes.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.5", + "version": "7.0.1-fb-unitTypes.6", "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 3e2e243523..23e0ff3270 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.0.1-fb-unitTypes.5", + "version": "7.0.1-fb-unitTypes.6", "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 cbed572597..1ed2ba8a33 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -5,7 +5,7 @@ Components, models, actions, and utility functions for LabKey applications and p *Released*: X 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, pg) with updated display precision for existing units + - 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 diff --git a/packages/components/src/internal/util/measurement.test.ts b/packages/components/src/internal/util/measurement.test.ts index 9b805815d5..caa7c2c294 100644 --- a/packages/components/src/internal/util/measurement.test.ts +++ b/packages/components/src/internal/util/measurement.test.ts @@ -144,7 +144,7 @@ describe('MetricUnit utils', () => { expect(getAltUnitKeys('uL')).toEqual(expectedUlOptions); expect(getAltUnitKeys('mL')).toEqual(expectedUlOptions); - const expectedGOptions = ['g', 'mg', 'kg', 'ug', 'ng', 'pg']; + const expectedGOptions = ['g', 'mg', 'kg', 'ug', 'ng']; expect(getAltUnitKeys('g')).toEqual(expectedGOptions); expect(getAltUnitKeys('kg')).toEqual(expectedGOptions); diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index 9de8219297..54ffbdedc6 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -108,7 +108,7 @@ export const MEASUREMENT_UNITS: Record = { longLabelPlural: 'grams', kind: UNITS_KIND.MASS, ratio: 1, - displayPrecision: 12, // enable smallest precision of pg + displayPrecision: 12, // enable smallest precision of pg and allow up to 3 decimal places for ng }, mg: { baseUnit: 'g', @@ -126,7 +126,7 @@ export const MEASUREMENT_UNITS: Record = { longLabelPlural: 'kilograms', kind: UNITS_KIND.MASS, ratio: 1000, - displayPrecision: 15, // enable smallest precision of pg + displayPrecision: 15, }, ug: { baseUnit: 'g', @@ -135,7 +135,7 @@ export const MEASUREMENT_UNITS: Record = { longLabelPlural: 'micrograms', kind: UNITS_KIND.MASS, ratio: 0.000001, - displayPrecision: 6, // enable smallest precision of pg + displayPrecision: 6, }, ng: { baseUnit: 'g', @@ -146,15 +146,6 @@ export const MEASUREMENT_UNITS: Record = { ratio: 0.000000001, displayPrecision: 3, }, - pg: { - baseUnit: 'g', - label: 'pg', - longLabelSingular: 'picogram', - longLabelPlural: 'picograms', - kind: UNITS_KIND.MASS, - ratio: 0.000000000001, - displayPrecision: 0, - }, ml: { baseUnit: 'mL', label: 'mL', From e425833ff5917053e8f68d892ac4e98e6711a7c6 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 3 Dec 2025 15:10:25 -0800 Subject: [PATCH 10/23] update readme --- packages/components/releaseNotes/components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index 1ed2ba8a33..6d2d0e0063 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,7 +1,7 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages -### version 6.X +### version 7.X *Released*: X 2025 - Sample Amount/Units polish: part 2 - Introduced a two-tier unit selection system (Amount Type, Display Units) in sample designer From f1466412d9d68053d9a6bd59be416f332d505078 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 4 Dec 2025 09:48:59 -0800 Subject: [PATCH 11/23] merge from develop --- packages/components/package-lock.json | 4 +- .../SampleTypePropertiesPanel.test.tsx | 42 +++++++++---------- .../src/internal/util/measurement.test.ts | 12 +++--- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 97fb15ecb8..8756e8e468 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-fb-unitTypes.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.0", + "version": "7.1.1-fb-unitTypes.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", 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 1fae14d0bd..93ffecd229 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx @@ -272,31 +272,31 @@ describe('SampleTypePropertiesPanel', () => { expect(aliquotField.textContent).toEqual('Aliquot Naming Pattern'); }); - test('getValidUnitKinds', () => { - it('returns all unit kinds when lockUnitKind and metricUnit are undefined', () => { - const result = getValidUnitKinds(); - expect(result).toEqual(Object.values(UnitKinds)); - }); +}); - it('returns all unit kinds when lockUnitKind is false', () => { - const result = getValidUnitKinds(false, 'mL'); - expect(result).toEqual(Object.values(UnitKinds)); - }); +describe('getValidUnitKinds', () => { + test('returns all unit kinds when lockUnitKind and metricUnit are undefined', () => { + const result = getValidUnitKinds(); + expect(result).toEqual(Object.values(UnitKinds)); + }); - it('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 all unit kinds when lockUnitKind is false', () => { + const result = getValidUnitKinds(false, 'mL'); + expect(result).toEqual(Object.values(UnitKinds)); + }); - it('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 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]]); + }); - it('returns only NONE when lockUnitKind is true and metricUnit is undefined', () => { - const result = getValidUnitKinds(true); - expect(result).toEqual([UnitKinds[UNITS_KIND.NONE]]); - }); + 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/util/measurement.test.ts b/packages/components/src/internal/util/measurement.test.ts index caa7c2c294..cbbb77e9bf 100644 --- a/packages/components/src/internal/util/measurement.test.ts +++ b/packages/components/src/internal/util/measurement.test.ts @@ -134,9 +134,9 @@ describe('MetricUnit utils', () => { ]) ); - expect(getMetricUnitOptions(null).length).toBe(10); - expect(getMetricUnitOptions('').length).toBe(10); - expect(getMetricUnitOptions('bad').length).toBe(10); + expect(getMetricUnitOptions(null).length).toBe(9); + expect(getMetricUnitOptions('').length).toBe(9); + expect(getMetricUnitOptions('bad').length).toBe(9); }); test('getAltUnitKeys', () => { @@ -162,9 +162,9 @@ describe('MetricUnit utils', () => { ]); // include all options when no unitTypeStr or an invalid unitTypeStr is provided - expect(getAltUnitKeys(null).length).toBe(19); - expect(getAltUnitKeys('').length).toBe(19); - expect(getAltUnitKeys('bad').length).toBe(19); + expect(getAltUnitKeys(null).length).toBe(18); + expect(getAltUnitKeys('').length).toBe(18); + expect(getAltUnitKeys('bad').length).toBe(18); }); test('getMeasurementUnit', () => { From 297c589c240af4e366d90d1d30617cdc09227294 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 4 Dec 2025 20:44:21 -0800 Subject: [PATCH 12/23] code review --- .../src/internal/components/forms/input/AmountUnitInput.tsx | 4 ++-- packages/components/src/internal/util/measurement.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx index 2ff70f9804..60b9649288 100644 --- a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx +++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx @@ -6,7 +6,7 @@ import { QuerySelect } from '../QuerySelect'; import { getContainerFilterForLookups } from '../../../query/api'; import { FieldLabel } from '../FieldLabel'; import { InputRendererProps } from './types'; -import { caseInsensitive, generateId, isValidSampleAmountWithError } 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'; @@ -46,7 +46,7 @@ export const AmountUnitInput: FC = memo(props => { }, [setDisabled]); const onAmountChange = useCallback((name: string, value: any) => { - const errorMsg = isValidSampleAmountWithError(value); + const errorMsg = getInvalidSampleAmountMessage(value); setAmountError(errorMsg); }, []); diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index 54ffbdedc6..efd4ee77a6 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -181,7 +181,7 @@ export const MEASUREMENT_UNITS: Record = { kind: UNITS_KIND.COUNT, ratio: 1, displayPrecision: 2, - altLabels: ['blocks', 'bottle', 'box', 'cells', 'kit', 'pack', 'pcs', 'slides', 'tests'], + altLabels: ['bottles', 'blocks', 'boxes', 'cells', 'kits', 'packs', 'pieces', 'slides', 'tests', 'unit'], }, }; From ac32b18d87d87a9ea5e469076a69596adbce7a3e Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 4 Dec 2025 20:44:45 -0800 Subject: [PATCH 13/23] code review --- .../src/internal/components/editable/utils.ts | 4 +- .../components/samples/StorageAmountInput.tsx | 4 +- .../src/internal/util/utils.test.ts | 68 +++++++++---------- .../components/src/internal/util/utils.ts | 2 +- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts index 791129f113..ff02feb32c 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, isValidSampleAmountWithError } from '../../util/utils'; +import { isBoolean, isFloat, isInteger, getInvalidSampleAmountMessage } from '../../util/utils'; import { incrementClientSideMetricCount } from '../../actions'; import { CellMessage } from './models'; @@ -66,7 +66,7 @@ export const getValidatedEditableGridValue = (origValue: any, col: QueryColumn): message = 'Invalid decimal'; } 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 = isValidSampleAmountWithError(value); + 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/samples/StorageAmountInput.tsx b/packages/components/src/internal/components/samples/StorageAmountInput.tsx index ac709a5a97..2af16b90af 100644 --- a/packages/components/src/internal/components/samples/StorageAmountInput.tsx +++ b/packages/components/src/internal/components/samples/StorageAmountInput.tsx @@ -9,7 +9,7 @@ import { isMeasurementUnitIgnoreCase, UnitModel, } from '../../util/measurement'; -import { isValidSampleAmountWithError } from '../../util/utils'; +import { getInvalidSampleAmountMessage } from '../../util/utils'; interface Props { amountChangedHandler: (amount: string) => void; @@ -111,7 +111,7 @@ export const StorageAmountInput: FC = memo(props => { {preferredUnitMessage} - {isValidSampleAmountWithError(amountInput)} + {getInvalidSampleAmountMessage(amountInput)} ); diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index d99a0a9720..f7be1ea9dd 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -46,7 +46,7 @@ import { isQuotedWithDelimiters, isSameWithStringCompare, isSetEqual, - isValidSampleAmountWithError, + getInvalidSampleAmountMessage, makeCommaSeparatedString, parseCsvString, parseScientificInt, @@ -1231,59 +1231,59 @@ describe('isAllowedSampleAmount', () => { }); }); -describe('isValidSampleAmountWithError', () => { +describe('getInvalidSampleAmountMessage', () => { it('returns undefined for null or undefined', () => { - expect(isValidSampleAmountWithError(null)).toBeNull(); - expect(isValidSampleAmountWithError(undefined)).toBeNull(); - expect(isValidSampleAmountWithError('')).toBeNull(); + expect(getInvalidSampleAmountMessage(null)).toBeNull(); + expect(getInvalidSampleAmountMessage(undefined)).toBeNull(); + expect(getInvalidSampleAmountMessage('')).toBeNull(); }); it('returns undefined for valid in range numeric values', () => { - expect(isValidSampleAmountWithError(0)).toBeNull(); - expect(isValidSampleAmountWithError(123)).toBeNull(); - expect(isValidSampleAmountWithError(123.45)).toBeNull(); - expect(isValidSampleAmountWithError(1.1e-100)).toBeNull(); - expect(isValidSampleAmountWithError(1.1e100)).toBeNull(); + 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(isValidSampleAmountWithError('0')).toBeNull(); - expect(isValidSampleAmountWithError('123')).toBeNull(); - expect(isValidSampleAmountWithError('123.45')).toBeNull(); - expect(isValidSampleAmountWithError('1.1E-100')).toBeNull(); - expect(isValidSampleAmountWithError('1.1E100')).toBeNull(); - expect(isValidSampleAmountWithError(1.7e308)).toBeNull(); + 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(isValidSampleAmountWithError({})).toBe('Please enter a valid numeric value for amount.'); - expect(isValidSampleAmountWithError([])).toBe('Please enter a valid numeric value for amount.'); - expect(isValidSampleAmountWithError('abc')).toBe('Please enter a valid numeric value for amount.'); - expect(isValidSampleAmountWithError('123abc')).toBe('Please enter a valid numeric value for amount.'); + 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(isValidSampleAmountWithError(-1)).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError('-1')).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError(-0.0001)).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError('-0.0001')).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError(-1.1e-100)).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError(-1.1e100)).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError('-1.1E-100')).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError('-1.1E100')).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError(-Infinity)).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError('-Infinity')).toBe('Amount must be a non-negative value.'); - expect(isValidSampleAmountWithError(-1.8e308)).toBe('Amount must be a non-negative value.'); + 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(isValidSampleAmountWithError(Infinity)).toBe( + expect(getInvalidSampleAmountMessage(Infinity)).toBe( 'Infinite or extremely large values are not allowed for amount.' ); - expect(isValidSampleAmountWithError('Infinity')).toBe( + expect(getInvalidSampleAmountMessage('Infinity')).toBe( 'Infinite or extremely large values are not allowed for amount.' ); - expect(isValidSampleAmountWithError(1.8e308)).toBe( + expect(getInvalidSampleAmountMessage(1.8e308)).toBe( 'Infinite or extremely large values are not allowed for amount.' ); }); diff --git a/packages/components/src/internal/util/utils.ts b/packages/components/src/internal/util/utils.ts index df98195257..b30e0fa5cb 100644 --- a/packages/components/src/internal/util/utils.ts +++ b/packages/components/src/internal/util/utils.ts @@ -456,7 +456,7 @@ export function isAllowedSampleAmount(value: unknown): boolean { return false; } -export const isValidSampleAmountWithError = (v: any): any => { +export const getInvalidSampleAmountMessage = (v: any): any => { if (isAllowedSampleAmount(v)) return null; if (!isFloat(v)) return 'Please enter a valid numeric value for amount.'; From 319a809be09bf0e475f2ad58c271fcb1c6c46304 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 4 Dec 2025 21:16:56 -0800 Subject: [PATCH 14/23] bug fixes --- .../samples/SampleTypeDesigner.tsx | 5 ++-- .../SampleTypePropertiesPanel.test.tsx | 1 - .../samples/SampleTypePropertiesPanel.tsx | 24 +++++++++---------- .../domainproperties/samples/models.ts | 14 ++++++----- 4 files changed, 21 insertions(+), 23 deletions(-) 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 93ffecd229..31d6008aab 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx @@ -155,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' }, diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index d0f98fd2d9..7c5710e592 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -80,7 +80,7 @@ export const UnitKinds: Record = { value: UNITS_KIND.NONE, label: 'Any', hideSubSelect: true, - msg: 'Amounts can be entered in any unit and won’t be converted.', + msg: 'Amounts can be entered in any unit and won\'t be converted when stored or displayed.', }, [UNITS_KIND.MASS]: { value: UNITS_KIND.MASS, @@ -94,7 +94,7 @@ export const UnitKinds: Record = { value: UNITS_KIND.COUNT, label: 'Other', hideSubSelect: true, - msg: 'Amounts can be entered as unit, pcs, pack, blocks, slides, cells, box, kit, tests, or bottle and won’t be converted.', + msg: 'Amounts can be entered as bottles, blocks, boxes, cells, kits, packs, pieces, slides, tests, or unit and won\'t be converted.', }, }; @@ -295,8 +295,7 @@ class SampleTypePropertiesPanelImpl extends PureComponent ({ isValid }), @@ -323,11 +322,11 @@ class SampleTypePropertiesPanelImpl extends PureComponent
- +
@@ -636,12 +634,12 @@ class SampleTypePropertiesPanelImpl extends PureComponent
diff --git a/packages/components/src/internal/components/domainproperties/samples/models.ts b/packages/components/src/internal/components/domainproperties/samples/models.ts index a482785208..f6fee0a0e7 100644 --- a/packages/components/src/internal/components/domainproperties/samples/models.ts +++ b/packages/components/src/internal/components/domainproperties/samples/models.ts @@ -51,6 +51,9 @@ export class SampleTypeModel extends Record({ importAliases = { ...aliases }; } + let metricUnit = options?.get('metricUnit'); + 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, @@ -58,7 +61,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({}), }); } @@ -72,18 +75,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 { @@ -109,7 +112,6 @@ export interface MetricUnitProps { metricUnitHelpMsg?: string; metricUnitLabel?: string; metricUnitOptions?: { label: string; value: string }[]; - metricUnitRequired?: boolean; } export interface AliquotNamePatternProps { From c0211a5e5c18c77afcba115d913d8bf5b2cbfce5 Mon Sep 17 00:00:00 2001 From: XingY Date: Thu, 4 Dec 2025 21:19:50 -0800 Subject: [PATCH 15/23] bug fixes --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 8756e8e468..12ea45a3f4 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.1", + "version": "7.1.1-fb-unitTypes.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.1", + "version": "7.1.1-fb-unitTypes.2", "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 746625ee71..6cfd2be783 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.1", + "version": "7.1.1-fb-unitTypes.2", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From 066d426e9f64467a02cb993e4d20342c6de3d2cc Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 5 Dec 2025 13:45:36 -0800 Subject: [PATCH 16/23] fix unit dropdown --- .../components/src/internal/util/measurement.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index efd4ee77a6..f18bdcf452 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -235,17 +235,16 @@ export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolea 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 }); - } else { - options.push({ value: value.label, label: value.label + ' (' + value.longLabelPlural + ')' }); - } - - if (unit && value.kind === unit.kind) { + if (value.kind === UNITS_KIND.COUNT) { unit.altLabels?.forEach(altLabel => { options.push({ value: altLabel, label: altLabel }); }); } + else if (!showLongLabel) { + options.push({ value: value.label, label: value.label }); + } else { + options.push({ value: value.label, label: value.label + ' (' + value.longLabelPlural + ')' }); + } } } return options; From 03cb09ba01d0428ca15b303af350480bc4b092dd Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 5 Dec 2025 14:08:20 -0800 Subject: [PATCH 17/23] fix updating other unit from sample detail panel --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 12ea45a3f4..9fdccf6a8a 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.2", + "version": "7.1.1-fb-unitTypes.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.2", + "version": "7.1.1-fb-unitTypes.3", "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 6cfd2be783..aab71ee065 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.2", + "version": "7.1.1-fb-unitTypes.3", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ From b398265bbb5921462f379cf364122b80e89e309b Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 5 Dec 2025 16:24:12 -0800 Subject: [PATCH 18/23] fix unit dropdown --- packages/components/package-lock.json | 4 +-- packages/components/package.json | 2 +- .../src/internal/util/measurement.test.ts | 27 ++++++------------- .../src/internal/util/measurement.ts | 5 ++-- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 9fdccf6a8a..3fa624221f 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.3", + "version": "7.1.1-fb-unitTypes.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.3", + "version": "7.1.1-fb-unitTypes.4", "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 aab71ee065..765ab522b5 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.3", + "version": "7.1.1-fb-unitTypes.4", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/util/measurement.test.ts b/packages/components/src/internal/util/measurement.test.ts index cbbb77e9bf..d6f6a47238 100644 --- a/packages/components/src/internal/util/measurement.test.ts +++ b/packages/components/src/internal/util/measurement.test.ts @@ -134,9 +134,9 @@ describe('MetricUnit utils', () => { ]) ); - expect(getMetricUnitOptions(null).length).toBe(9); - expect(getMetricUnitOptions('').length).toBe(9); - expect(getMetricUnitOptions('bad').length).toBe(9); + expect(getMetricUnitOptions(null).length).toBe(18); + expect(getMetricUnitOptions('').length).toBe(18); + expect(getMetricUnitOptions('bad').length).toBe(18); }); test('getAltUnitKeys', () => { @@ -148,18 +148,7 @@ describe('MetricUnit utils', () => { expect(getAltUnitKeys('g')).toEqual(expectedGOptions); expect(getAltUnitKeys('kg')).toEqual(expectedGOptions); - expect(getAltUnitKeys('unit')).toEqual([ - 'unit', - 'blocks', - 'bottle', - 'box', - 'cells', - 'kit', - 'pack', - 'pcs', - 'slides', - 'tests', - ]); + expect(getAltUnitKeys('unit')).toEqual(['bottles', 'blocks', '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(18); @@ -173,12 +162,12 @@ describe('MetricUnit utils', () => { expect(getMeasurementUnit('invalidUnit')).toBeNull(); expect(getMeasurementUnit('mL')).toEqual(MEASUREMENT_UNITS.ml); expect(getMeasurementUnit('ML')).toEqual(MEASUREMENT_UNITS.ml); - const unit = getMeasurementUnit('pcs'); + const unit = getMeasurementUnit('pieces'); expect(unit).toEqual({ ...MEASUREMENT_UNITS.unit, - label: 'pcs', - longLabelSingular: 'pcs', - longLabelPlural: 'pcs', + 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 f18bdcf452..1daf09afe5 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -236,7 +236,7 @@ export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolea for (const [key, value] of Object.entries(MEASUREMENT_UNITS)) { if (!unit || value.kind === unit.kind) { if (value.kind === UNITS_KIND.COUNT) { - unit.altLabels?.forEach(altLabel => { + value.altLabels?.forEach(altLabel => { options.push({ value: altLabel, label: altLabel }); }); } @@ -274,10 +274,11 @@ export function getAltUnitKeys(unitTypeStr): string[] { 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); } }); From d8e3dcf9bc48f5260624459f79602e468907be35 Mon Sep 17 00:00:00 2001 From: XingY Date: Fri, 5 Dec 2025 17:55:25 -0800 Subject: [PATCH 19/23] fix more tests --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- .../components/src/internal/util/measurement.ts | 16 +++++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 3fa624221f..3cbcaf4a1f 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.4", + "version": "7.1.1-fb-unitTypes.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.4", + "version": "7.1.1-fb-unitTypes.5", "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 765ab522b5..781eef2935 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.4", + "version": "7.1.1-fb-unitTypes.5", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index 1daf09afe5..b10abf0e47 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -236,14 +236,16 @@ export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolea for (const [key, value] of Object.entries(MEASUREMENT_UNITS)) { if (!unit || value.kind === unit.kind) { if (value.kind === UNITS_KIND.COUNT) { - value.altLabels?.forEach(altLabel => { - options.push({ value: altLabel, label: altLabel }); - }); + 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 if (!showLongLabel) { - options.push({ value: value.label, label: value.label }); - } else { - options.push({ value: value.label, label: value.label + ' (' + value.longLabelPlural + ')' }); + else { + options.push({ value: value.label, label: value.label + (showLongLabel ? ' (' + value.longLabelPlural + ')' : '') }); } } } From 84a9a260f181a6853ca1dbadfb7bbca92de9415d Mon Sep 17 00:00:00 2001 From: XingY Date: Sat, 6 Dec 2025 22:06:09 -0800 Subject: [PATCH 20/23] fix more tests --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- .../domainproperties/samples/models.ts | 2 +- .../samples/StorageAmountInput.test.tsx | 18 ++++++++++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index 3cbcaf4a1f..e9928e2c58 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.5", + "version": "7.1.1-fb-unitTypes.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.5", + "version": "7.1.1-fb-unitTypes.6", "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 781eef2935..cfbd911ded 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.5", + "version": "7.1.1-fb-unitTypes.6", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/components/domainproperties/samples/models.ts b/packages/components/src/internal/components/domainproperties/samples/models.ts index f6fee0a0e7..621ea009cc 100644 --- a/packages/components/src/internal/components/domainproperties/samples/models.ts +++ b/packages/components/src/internal/components/domainproperties/samples/models.ts @@ -51,7 +51,7 @@ export class SampleTypeModel extends Record({ importAliases = { ...aliases }; } - let metricUnit = options?.get('metricUnit'); + 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({ diff --git a/packages/components/src/internal/components/samples/StorageAmountInput.test.tsx b/packages/components/src/internal/components/samples/StorageAmountInput.test.tsx index e5e5d3aafb..f117e37685 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.' + ); + }); }); From 27acd21fc134b48bc4a54452d2ab138fdf7fc748 Mon Sep 17 00:00:00 2001 From: XingY Date: Sun, 7 Dec 2025 14:20:50 -0800 Subject: [PATCH 21/23] fix more tests --- packages/components/package-lock.json | 4 ++-- packages/components/package.json | 2 +- packages/components/src/internal/util/measurement.test.ts | 2 +- packages/components/src/internal/util/measurement.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index e9928e2c58..e1fc7280a5 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.6", + "version": "7.1.1-fb-unitTypes.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.6", + "version": "7.1.1-fb-unitTypes.7", "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 cfbd911ded..5e8650b46e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.6", + "version": "7.1.1-fb-unitTypes.7", "description": "Components, models, actions, and utility functions for LabKey applications and pages", "sideEffects": false, "files": [ diff --git a/packages/components/src/internal/util/measurement.test.ts b/packages/components/src/internal/util/measurement.test.ts index d6f6a47238..32ffcf1106 100644 --- a/packages/components/src/internal/util/measurement.test.ts +++ b/packages/components/src/internal/util/measurement.test.ts @@ -148,7 +148,7 @@ describe('MetricUnit utils', () => { expect(getAltUnitKeys('g')).toEqual(expectedGOptions); expect(getAltUnitKeys('kg')).toEqual(expectedGOptions); - expect(getAltUnitKeys('unit')).toEqual(['bottles', 'blocks', 'boxes', 'cells', 'kits', 'packs', 'pieces', 'slides', 'tests', '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(18); diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index b10abf0e47..0dab2e7cfc 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -181,7 +181,7 @@ export const MEASUREMENT_UNITS: Record = { kind: UNITS_KIND.COUNT, ratio: 1, displayPrecision: 2, - altLabels: ['bottles', 'blocks', 'boxes', 'cells', 'kits', 'packs', 'pieces', 'slides', 'tests', 'unit'], + altLabels: ['blocks', 'bottles', 'boxes', 'cells', 'kits', 'packs', 'pieces', 'slides', 'tests', 'unit'], }, }; From dbad149514edc7a9c3c8de6f95f5befb0686d058 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 8 Dec 2025 11:31:24 -0800 Subject: [PATCH 22/23] lint --- packages/components/package.json | 2 +- .../components/releaseNotes/components.md | 4 ++-- .../SampleTypePropertiesPanel.test.tsx | 19 +++++++++---------- .../samples/SampleTypePropertiesPanel.tsx | 6 +++--- .../domainproperties/samples/models.ts | 3 +-- .../src/internal/components/editable/utils.ts | 2 +- .../samples/StorageAmountInput.test.tsx | 2 +- .../src/internal/util/measurement.test.ts | 13 ++++++++++++- .../src/internal/util/measurement.ts | 15 ++++++++------- .../src/internal/util/utils.test.ts | 2 +- 10 files changed, 39 insertions(+), 29 deletions(-) diff --git a/packages/components/package.json b/packages/components/package.json index 5e8650b46e..65e8c8bc11 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -1,6 +1,6 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.7", + "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 c70dee755d..e31814bf10 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -1,8 +1,8 @@ # @labkey/components Components, models, actions, and utility functions for LabKey applications and pages -### version 7.X -*Released*: X 2025 +### 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 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 31d6008aab..deef26101f 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.test.tsx @@ -115,15 +115,15 @@ describe('SampleTypePropertiesPanel', () => { container = renderWithAppContext( ); }); @@ -180,9 +180,9 @@ describe('SampleTypePropertiesPanel', () => { renderWithAppContext( ); }); @@ -201,9 +201,9 @@ describe('SampleTypePropertiesPanel', () => { renderWithAppContext( ); }); @@ -255,11 +255,11 @@ describe('SampleTypePropertiesPanel', () => { renderWithAppContext( ); @@ -270,7 +270,6 @@ describe('SampleTypePropertiesPanel', () => { const aliquotField = fields[4]; expect(aliquotField.textContent).toEqual('Aliquot Naming Pattern'); }); - }); describe('getValidUnitKinds', () => { diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx index 7c5710e592..3d9ea635f0 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypePropertiesPanel.tsx @@ -80,7 +80,7 @@ export const UnitKinds: Record = { 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.', + msg: "Amounts can be entered in any unit and won't be converted when stored or displayed.", }, [UNITS_KIND.MASS]: { value: UNITS_KIND.MASS, @@ -94,7 +94,7 @@ export const UnitKinds: Record = { 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.', + msg: "Amounts can be entered as bottles, blocks, boxes, cells, kits, packs, pieces, slides, tests, or unit and won't be converted.", }, }; @@ -322,7 +322,7 @@ class SampleTypePropertiesPanelImpl extends PureComponent { test('Large amount error', () => { const unit = 'uL'; - const model = new UnitModel(1E310, unit); + const model = new UnitModel(1e310, unit); render( { expect(getAltUnitKeys('g')).toEqual(expectedGOptions); expect(getAltUnitKeys('kg')).toEqual(expectedGOptions); - expect(getAltUnitKeys('unit')).toEqual(['blocks', 'bottles', 'boxes', 'cells', 'kits', 'packs', 'pieces', 'slides', 'tests', '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(18); diff --git a/packages/components/src/internal/util/measurement.ts b/packages/components/src/internal/util/measurement.ts index 0dab2e7cfc..994103be1a 100644 --- a/packages/components/src/internal/util/measurement.ts +++ b/packages/components/src/internal/util/measurement.ts @@ -236,16 +236,19 @@ export function getMetricUnitOptions(metricUnit?: string, showLongLabel?: boolea for (const [key, value] of Object.entries(MEASUREMENT_UNITS)) { if (!unit || value.kind === unit.kind) { if (value.kind === UNITS_KIND.COUNT) { - if (showLongLabel) // used by designer + 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 + (showLongLabel ? ' (' + value.longLabelPlural + ')' : '') }); + } else { + options.push({ + value: value.label, + label: value.label + (showLongLabel ? ' (' + value.longLabelPlural + ')' : ''), + }); } } } @@ -278,9 +281,7 @@ export function getAltUnitKeys(unitTypeStr): string[] { if (!unit || value.kind === unit.kind) { if (value.altLabels) { options.push(...value.altLabels); - } - else - options.push(value.label); + } else options.push(value.label); } }); diff --git a/packages/components/src/internal/util/utils.test.ts b/packages/components/src/internal/util/utils.test.ts index f7be1ea9dd..cb425a5351 100644 --- a/packages/components/src/internal/util/utils.test.ts +++ b/packages/components/src/internal/util/utils.test.ts @@ -31,6 +31,7 @@ import { getCommonDataValues, getDataStyling, getIconFontCls, + getInvalidSampleAmountMessage, getUpdatedData, getValueFromRow, getValuesSummary, @@ -46,7 +47,6 @@ import { isQuotedWithDelimiters, isSameWithStringCompare, isSetEqual, - getInvalidSampleAmountMessage, makeCommaSeparatedString, parseCsvString, parseScientificInt, From 6544c5cc27df180af8422d300befbb5f962f1e44 Mon Sep 17 00:00:00 2001 From: XingY Date: Mon, 8 Dec 2025 11:32:45 -0800 Subject: [PATCH 23/23] publish --- packages/components/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index e1fc7280a5..0c41b9a91b 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.7", + "version": "7.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "7.1.1-fb-unitTypes.7", + "version": "7.1.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1",