Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/components/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@labkey/components",
"version": "6.71.0",
"version": "6.71.1",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
6 changes: 6 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version 6.71.1
*Released*: 20 November 2025
- Sample Amount/Units polish
- Disallow negative sample amounts: Update `getValidatedEditableGridValue` to check for negative amount values
- Added `AmountUnitInput` to show amount/units input side-by-side on bulk add/edit

### version 6.71.0
*Released*: 20 November 2025
- Line chart trendline options for provided parameters to CalculateCurveFit API
Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ import {
DOMAIN_FIELD_REQUIRED,
DOMAIN_FIELD_TYPE,
DOMAIN_RANGE_VALIDATOR,
NON_NEGATIVE_NUMBER_CONCEPT_URI,
RANGE_URIS,
SAMPLE_TYPE_CONCEPT_URI,
STORAGE_UNIQUE_ID_CONCEPT_URI,
Expand Down Expand Up @@ -1535,6 +1536,7 @@ export {
NavigationBar,
NO_UPDATES_MESSAGE,
NoLinkRenderer,
NON_NEGATIVE_NUMBER_CONCEPT_URI,
NOT_ANY_FILTER_TYPE,
NOT_IN_EXP_DESCENDANTS_OF_FILTER_TYPE,
Notifications,
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export const MODIFIED_TIMESTAMP_CONCEPT_URI = 'http://www.labkey.org/types#modif
export const SMILES_CONCEPT_URI = 'http://www.labkey.org/exp/xml#smiles';
export const AUTO_INT_CONCEPT_URI = 'http://www.labkey.org/types#autoInt';
export const CALCULATED_CONCEPT_URI = 'http://www.labkey.org/exp/xml#calculated';
export const NON_NEGATIVE_NUMBER_CONCEPT_URI = 'http://www.labkey.org/types#nonNegativeNumber';

export const UNLIMITED_TEXT_LENGTH = 2147483647; // Integer.MAX_VALUE
export const MAX_TEXT_LENGTH = 4000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { ComponentsAPIWrapper, getDefaultAPIWrapper } from '../../../APIWrapper'

import { GENID_SYNTAX_STRING } from '../NameExpressionGenIdBanner';

import { IImportAlias, IParentAlias, IParentOption, FolderConfigurableDataType } from '../../entities/models';
import { FolderConfigurableDataType, IImportAlias, IParentAlias, IParentOption } from '../../entities/models';
import { SCHEMAS } from '../../../schemas';
import {
getHelpLink,
Expand Down Expand Up @@ -115,10 +115,10 @@ interface Props {
onCancel: () => void;
onChange?: (model: SampleTypeModel) => void;
onComplete: (response: DomainDesign) => void;
sampleAliasCaption?: string;
sampleTypeCaption?: string;
saveBtnText?: string;
showAliquotOptions?: boolean;
sampleAliasCaption?: string;
showLinkToStudy?: boolean;
showParentLabelPrefix?: boolean;
useSeparateDataClassesAliasMenu?: boolean;
Expand All @@ -138,7 +138,7 @@ interface State {
uniqueIdsConfirmed: boolean;
}
// Exported for testing
export class SampleTypeDesignerImpl extends React.PureComponent<Props & InjectedBaseDomainDesignerProps, State> {
export class SampleTypeDesignerImpl extends React.PureComponent<InjectedBaseDomainDesignerProps & Props, State> {
static defaultProps = {
api: getDefaultAPIWrapper(),
defaultSampleFieldConfig: DEFAULT_SAMPLE_FIELD_CONFIG,
Expand All @@ -156,7 +156,7 @@ export class SampleTypeDesignerImpl extends React.PureComponent<Props & Injected
validateNameExpressions: true,
};

constructor(props: Props & InjectedBaseDomainDesignerProps) {
constructor(props: InjectedBaseDomainDesignerProps & Props) {
super(props);

let domainDetails = this.props.initModel || DomainDetails.create();
Expand Down Expand Up @@ -550,15 +550,15 @@ export class SampleTypeDesignerImpl extends React.PureComponent<Props & Injected
if (isCommunityDistribution() || !model.isNew() || model.domain?.fields?.isEmpty()) {
return null;
}
return <UniqueIdBanner model={this.state.model} isFieldsPanel={true} onAddField={config.onAddField} />;
return <UniqueIdBanner isFieldsPanel={true} model={this.state.model} onAddField={config.onAddField} />;
};

getNumNewUniqueIdFields(): number {
const { model } = this.state;
return model.domain.fields.filter(field => field.isNew() && field.isUniqueIdField()).count();
}

getDomainDetails = (): { [key: string]: any } => {
getDomainDetails = (): Record<string, any> => {
const { model } = this.state;

const {
Expand Down Expand Up @@ -683,56 +683,31 @@ export class SampleTypeDesignerImpl extends React.PureComponent<Props & Injected

return (
<BaseDomainDesigner
name={model.name}
exception={model.exception}
domains={List.of(model.domain)}
exception={model.exception}
hasValidProperties={model.hasValidProperties()}
visitedPanels={visitedPanels}
submitting={submitting}
name={model.name}
onCancel={onCancel}
onFinish={this.onFinish}
saveBtnText={saveBtnText}
showUserComment={isUpdate && appPropertiesOnly}
submitting={submitting}
visitedPanels={visitedPanels}
>
<SampleTypePropertiesPanel
aliquotNamePatternProps={aliquotNamePatternProps}
api={api}
nounSingular={nounSingular}
nounPlural={nounPlural}
nameExpressionInfoUrl={nameExpressionInfoUrl}
nameExpressionPlaceholder={nameExpressionPlaceholder}
appPropertiesOnly={appPropertiesOnly}
controlledCollapse
dataClassAliasCaption={dataClassAliasCaption}
dataClassParentageLabel={dataClassParentageLabel}
dataClassTypeCaption={dataClassTypeCaption}
headerText={headerText}
helpTopic={helpTopic}
model={model}
parentOptions={parentOptions}
includeDataClasses={includeDataClasses}
useSeparateDataClassesAliasMenu={useSeparateDataClassesAliasMenu}
sampleAliasCaption={sampleAliasCaption}
sampleTypeCaption={sampleTypeCaption}
dataClassAliasCaption={dataClassAliasCaption}
dataClassTypeCaption={dataClassTypeCaption}
dataClassParentageLabel={dataClassParentageLabel}
onParentAliasChange={this.parentAliasChange}
onAddParentAlias={this.addParentAlias}
onRemoveParentAlias={this.removeParentAlias}
updateDupeParentAliases={this.updateDupes}
updateModel={this.onFieldChange}
controlledCollapse
initCollapsed={currentPanelIndex !== PROPERTIES_PANEL_INDEX}
panelStatus={
model.isNew()
? getDomainPanelStatus(PROPERTIES_PANEL_INDEX, currentPanelIndex, visitedPanels, firstState)
: 'COMPLETE'
}
validate={validatePanel === PROPERTIES_PANEL_INDEX}
onToggle={this.propertiesToggle}
appPropertiesOnly={appPropertiesOnly}
showLinkToStudy={_showLinkToStudy}
metricUnitProps={metricUnitProps}
onAddUniqueIdField={this.onAddUniqueIdField}
aliquotNamePatternProps={aliquotNamePatternProps}
namePreviewsLoading={namePreviewsLoading}
namePreviews={namePreviews}
onNameFieldHover={this.onNameFieldHover}
model={model}
nameExpressionGenIdProps={
isUpdate && options && hasGenIdInExpression
? {
Expand All @@ -745,26 +720,38 @@ export class SampleTypeDesignerImpl extends React.PureComponent<Props & Injected
}
: undefined
}
/>
<DomainForm
api={api.domain}
key={model.domain.domainId || 0}
appDomainHeaderRenderer={this.uniqueIdBannerRenderer}
domainIndex={0}
domain={model.domain}
headerTitle="Fields"
helpTopic={null} // null so that we don't show the "learn more about this tool" link for this domains
controlledCollapse
initCollapsed={currentPanelIndex !== DOMAIN_PANEL_INDEX}
validate={validatePanel === DOMAIN_PANEL_INDEX}
nameExpressionInfoUrl={nameExpressionInfoUrl}
nameExpressionPlaceholder={nameExpressionPlaceholder}
namePreviews={namePreviews}
namePreviewsLoading={namePreviewsLoading}
nounPlural={nounPlural}
nounSingular={nounSingular}
onAddParentAlias={this.addParentAlias}
onAddUniqueIdField={this.onAddUniqueIdField}
onNameFieldHover={this.onNameFieldHover}
onParentAliasChange={this.parentAliasChange}
onRemoveParentAlias={this.removeParentAlias}
onToggle={this.propertiesToggle}
panelStatus={
model.isNew()
? getDomainPanelStatus(1, currentPanelIndex, visitedPanels, firstState)
? getDomainPanelStatus(PROPERTIES_PANEL_INDEX, currentPanelIndex, visitedPanels, firstState)
: 'COMPLETE'
}
onChange={this.domainChangeHandler}
onToggle={this.formToggle}
parentOptions={parentOptions}
sampleAliasCaption={sampleAliasCaption}
sampleTypeCaption={sampleTypeCaption}
showLinkToStudy={_showLinkToStudy}
updateDupeParentAliases={this.updateDupes}
updateModel={this.onFieldChange}
useSeparateDataClassesAliasMenu={useSeparateDataClassesAliasMenu}
validate={validatePanel === PROPERTIES_PANEL_INDEX}
/>
<DomainForm
api={api.domain}
appDomainHeaderRenderer={this.uniqueIdBannerRenderer}
appPropertiesOnly={appPropertiesOnly}
controlledCollapse
domain={model.domain}
domainFormDisplayOptions={{
...domainFormDisplayOptions,
hideStudyPropertyTypes: !_showLinkToStudy,
Expand All @@ -791,47 +778,61 @@ export class SampleTypeDesignerImpl extends React.PureComponent<Props & Injected
"Updating a 'Samples Only' field to be 'Samples and Aliquots' will blank out the field values for all aliquots. This action cannot be undone. ",
},
}}
domainIndex={0}
groupedSystemFields={{ storedamount: ['units'], units: ['storedamount'] }}
headerTitle="Fields"
helpTopic={null} // null so that we don't show the "learn more about this tool" link for this domains
initCollapsed={currentPanelIndex !== DOMAIN_PANEL_INDEX}
key={model.domain.domainId || 0}
newFieldConfig={{
derivationDataScope: DERIVATION_DATA_SCOPES.PARENT_ONLY,
}}
onChange={this.domainChangeHandler}
onToggle={this.formToggle}
panelStatus={
model.isNew()
? getDomainPanelStatus(1, currentPanelIndex, visitedPanels, firstState)
: 'COMPLETE'
}
systemFields={options?.get('systemFields')}
validate={validatePanel === DOMAIN_PANEL_INDEX}
/>
{appPropertiesOnly && allowFolderExclusion && (
// appPropertiesOnly check will prevent this panel from showing in LKS and in LKB media types
<DataTypeFoldersPanel
controlledCollapse
dataTypeRowId={model?.rowId}
dataTypeName={model?.name}
dataTypeRowId={model?.rowId}
entityDataType={SampleTypeDataType}
relatedFolderConfigurableDataType="DashboardSampleType"
relatedDataTypeLabel="Include in Dashboard Insights graphs"
initCollapsed={currentPanelIndex !== FOLDERS_PANEL_INDEX}
onToggle={this.foldersToggle}
onUpdateExcludedFolders={this.onUpdateExcludedFolders}
relatedDataTypeLabel="Include in Dashboard Insights graphs"
relatedFolderConfigurableDataType="DashboardSampleType"
/>
)}
{error && <div className="domain-form-panel">{error && <Alert bsStyle="danger">{error}</Alert>}</div>}
{showUniqueIdConfirmation && (
<Modal
confirmText="Continue"
onCancel={this.onUniqueIdCancel}
onConfirm={this.onUniqueIdConfirm}
title={
'Updating ' +
SampleTypeDataType.typeNounSingular +
' with Unique ID field' +
(numNewUniqueIdFields !== 1 ? 's' : '')
}
onCancel={this.onUniqueIdCancel}
onConfirm={this.onUniqueIdConfirm}
confirmText="Continue"
>
{confirmModalMessage}
</Modal>
)}
<NameExpressionValidationModal
onHide={this.onNameExpressionWarningCancel}
onConfirm={this.onNameExpressionWarningConfirm}
warnings={nameExpressionWarnings}
onHide={this.onNameExpressionWarningCancel}
previews={namePreviews}
show={!!nameExpressionWarnings && !model.exception}
warnings={nameExpressionWarnings}
/>
</BaseDomainDesigner>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from './utils';
import { LookupCell } from './LookupCell';
import { DateInputCell } from './DateInputCell';
import { NON_NEGATIVE_NUMBER_CONCEPT_URI } from '../domainproperties/constants';

// CSS Order: top, right, bottom, left
export type BorderMask = [boolean, boolean, boolean, boolean];
Expand Down Expand Up @@ -532,6 +533,8 @@ export class Cell extends React.PureComponent<CellProps, undefined> {
.filter(vd => vd && vd.display !== undefined)
.reduce((v, vd, i) => v + (i > 0 ? ', ' : '') + vd.display, '');

const showMenu = (showLookup || !!col.inputRenderer) && (NON_NEGATIVE_NUMBER_CONCEPT_URI !== col?.conceptURI /*storedamount has inputRenderer but shouldn't show menu*/);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the comment, that helps. But as mentioned in the other PR, this still seems odd that we are tying together the NON_NEGATIVE_NUMBER_CONCEPT_URI and the new stored amount input renderer. Those seem like different parts/pieces.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree it's a bit odd. But it's also odd that we always show the menu dropdown if there is an inputRenderer. I'll leave as is for now, maybe will come back to it when we get to the rest of the amount/units polish.


return (
<>
<DisplayCell
Expand All @@ -551,7 +554,7 @@ export class Cell extends React.PureComponent<CellProps, undefined> {
placeholder={placeholder}
selected={selected}
selection={selection}
showMenu={showLookup || !!col.inputRenderer}
showMenu={showMenu}
targetRef={this.displayEl}
/>
{renderDragHandle && !this.isReadOnly && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { List, Map } from 'immutable';
import { QueryInfo } from '../../../public/QueryInfo';
import { QueryColumn, QueryLookup } from '../../../public/QueryColumn';

import { DATE_RANGE_URI } from '../domainproperties/constants';
import { DATE_RANGE_URI, NON_NEGATIVE_NUMBER_CONCEPT_URI } from '../domainproperties/constants';

import sampleSetQueryInfoJSON from '../../../test/data/sampleSetAllFieldTypes-getQueryDetails.json';

Expand Down Expand Up @@ -273,6 +273,42 @@ describe('getValidatedEditableGridValue', () => {
});
});

test('amount column', () => {
const amountCol = new QueryColumn({
jsonType: 'float',
conceptURI: NON_NEGATIVE_NUMBER_CONCEPT_URI,
caption: 'Amount',
});

const validValues = [null, undefined, '', ' ', 0, 1.1e3, '100', '0.0', 1.11, '1.11', 123.456e2];
validValues.forEach(value => {
expect(getValidatedEditableGridValue(value, amountCol)).toStrictEqual({
message: undefined,
value: Utils.isString(value) ? value.trim() : value,
});
});

const invalidValues = ['Bogus', true, NaN];
invalidValues.forEach(value => {
expect(getValidatedEditableGridValue(value, amountCol)).toStrictEqual({
message: {
message: 'Invalid decimal',
},
value,
});
});

const invalidNegative = ['-1', '-1.1', -1, -1.1, -1.1e3, -123.456e2];
invalidNegative.forEach(value => {
expect(getValidatedEditableGridValue(value, amountCol)).toStrictEqual({
message: {
message: 'Amount must be non-negative',
},
value,
});
});
});

test('boolean column', () => {
const boolCol = new QueryColumn({ jsonType: 'boolean' });

Expand Down
Loading