diff --git a/packages/components/package-lock.json b/packages/components/package-lock.json index bccd357bb4..d83fb8e684 100644 --- a/packages/components/package-lock.json +++ b/packages/components/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labkey/components", - "version": "6.71.0", + "version": "6.71.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labkey/components", - "version": "6.71.0", + "version": "6.71.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 b02aa74fff..a87b81e8c8 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -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": [ diff --git a/packages/components/releaseNotes/components.md b/packages/components/releaseNotes/components.md index ed1fce005f..dd8888c065 100644 --- a/packages/components/releaseNotes/components.md +++ b/packages/components/releaseNotes/components.md @@ -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 diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index d2dee15827..da22ed2dd6 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -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, @@ -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, diff --git a/packages/components/src/internal/components/domainproperties/DomainForm.tsx b/packages/components/src/internal/components/domainproperties/DomainForm.tsx index 794fad3cd9..182c5756c7 100644 --- a/packages/components/src/internal/components/domainproperties/DomainForm.tsx +++ b/packages/components/src/internal/components/domainproperties/DomainForm.tsx @@ -146,28 +146,28 @@ const DomainFormToolbar: FC = memo(props => {
{!domainFormDisplayOptions?.hideAddFieldsButton && ( )} {!domainFormDisplayOptions?.hideAddFieldsButton && ( Delete )} {shouldShowImportExport && ( Export @@ -177,26 +177,26 @@ const DomainFormToolbar: FC = memo(props => {
{!valueIsEmpty(search) && ( - Showing {fields.filter(f => f.visible).size} of {fields.size} {' '} - field{fields.size > 1 ? 's' : ''}. + Showing {fields.filter(f => f.visible).size} of {fields.size} field + {fields.size > 1 ? 's' : ''}. )}
Mode:
@@ -217,6 +217,8 @@ export interface DomainFormProps extends PropsWithChildren { domainFormDisplayOptions?: IDomainFormDisplayOptions; domainIndex?: number; fieldsAdditionalRenderer?: () => ReactNode; + // Map of grouped system fields, that should be enabled/disabled together + groupedSystemFields?: Record; headerPrefix?: string; // used as a string to remove from the heading when using the domain.name headerTitle?: string; helpNoun?: string; @@ -602,10 +604,10 @@ export class DomainFormImpl extends React.PureComponent if (deletableSelectedFieldsCount === 0) { return (

None of the selected fields can be deleted.

@@ -621,11 +623,11 @@ export class DomainFormImpl extends React.PureComponent return (

{howManyDeleted} will be deleted.

@@ -703,8 +705,12 @@ export class DomainFormImpl extends React.PureComponent }; onSystemFieldEnable = (field: string, enable: boolean): void => { - const { domain } = this.props; - this.onDomainChange(handleSystemFieldUpdates(domain, field, enable)); + const { domain, groupedSystemFields } = this.props; + let updatedDomain = handleSystemFieldUpdates(domain, field, enable); + groupedSystemFields?.[field.toLowerCase()]?.forEach(groupedField => { + updatedDomain = handleSystemFieldUpdates(updatedDomain, groupedField, enable); + }); + this.onDomainChange(updatedDomain); }; onFieldsChange = (changes: List, index: number, expand: boolean, skipDirtyCheck = false): void => { @@ -923,9 +929,9 @@ export class DomainFormImpl extends React.PureComponent
@@ -957,11 +963,11 @@ export class DomainFormImpl extends React.PureComponent const fieldName = field && field.name && field.name.trim().length > 0 ? {field.name} : 'this field'; return ( this.onDeleteConfirm(confirmDeleteRowIndex)} - onCancel={this.onConfirmCancel} confirmClass="btn-danger" confirmText="Yes, Remove Field" + onCancel={this.onConfirmCancel} + onConfirm={() => this.onDeleteConfirm(confirmDeleteRowIndex)} + title="Confirm Remove Field" >
Are you sure you want to remove {fieldName}?{' '} @@ -1090,11 +1096,11 @@ export class DomainFormImpl extends React.PureComponent <> domainKindName: domain.domainKindName, } } - fileSpecificCallback={Map({ '.json': this.importFieldsFromJson })} + showAcceptedFormats /> {shouldShowInferFromFile && this.state.filePreviewMsg && ( {this.state.filePreviewMsg} @@ -1199,7 +1205,7 @@ export class DomainFormImpl extends React.PureComponent visibleFieldsCount !== 0 && visibleSelection.size === visibleFieldsCount ? 'Clear All' : 'Clear'; return ( - +
{reservedFieldsMsg}
@@ -1219,10 +1225,10 @@ export class DomainFormImpl extends React.PureComponent
@@ -1257,39 +1263,39 @@ export class DomainFormImpl extends React.PureComponent return ( { - this.refsArray[i] = ref; - }} + allowUniqueConstraintProperties={domain.allowUniqueConstraintProperties} + appPropertiesOnly={appPropertiesOnly} + availableTypes={availableTypes} + defaultDefaultValueType={domain.defaultDefaultValueType} + defaultValueOptions={domain.defaultValueOptions} + domainContainerPath={domain.container} + domainFormDisplayOptions={domainFormDisplayOptions} domainId={domain.domainId} - helpNoun={helpNoun} - key={key} + domainIndex={domainIndex} + dragging={dragId === i} + expanded={expandedRowIndex === i} field={field} + fieldDetailsInfo={fieldDetails.detailsInfo} fieldError={this.getFieldError(domain, i)} getDomainFields={this.getDomainFields} - fieldDetailsInfo={fieldDetails.detailsInfo} - domainIndex={domainIndex} + helpNoun={helpNoun} index={i} - expanded={expandedRowIndex === i} - onChange={this.onFieldsChange} - onExpand={this.onFieldExpandToggle} - onDelete={this.onDeleteField} - maxPhiLevel={maxPhiLevel} - dragging={dragId === i} - availableTypes={availableTypes} - allowUniqueConstraintProperties={domain.allowUniqueConstraintProperties} - showDefaultValueSettings={domain.showDefaultValueSettings} - defaultDefaultValueType={domain.defaultDefaultValueType} - defaultValueOptions={domain.defaultValueOptions} - appPropertiesOnly={appPropertiesOnly} isDragDisabled={ !valueIsEmpty(search) || domainFormDisplayOptions.isDragDisabled || field.isCalculatedField() } - domainFormDisplayOptions={domainFormDisplayOptions} - domainContainerPath={domain.container} - schemaName={schemaName ?? domain.schemaName} + key={key} + maxPhiLevel={maxPhiLevel} + onChange={this.onFieldsChange} + onDelete={this.onDeleteField} + onExpand={this.onFieldExpandToggle} queryName={queryName ?? domain.queryName} + ref={ref => { + this.refsArray[i] = ref; + }} + schemaName={schemaName ?? domain.schemaName} + showDefaultValueSettings={domain.showDefaultValueSettings} /> ); }) @@ -1356,17 +1362,17 @@ export class DomainFormImpl extends React.PureComponent
{showHeader && ( {children} @@ -1379,8 +1385,8 @@ export class DomainFormImpl extends React.PureComponent <> {systemFields && ( )} @@ -1407,7 +1413,7 @@ export class DomainFormImpl extends React.PureComponent
{helpTopic && (
- + Learn more about this tool
@@ -1454,10 +1460,10 @@ export class DomainFormImpl extends React.PureComponent {filePreviewData && !domainFormDisplayOptions?.hideImportData && ( )}
@@ -1465,8 +1471,8 @@ export class DomainFormImpl extends React.PureComponent
{hasException && domain.domainException.severity === SEVERITY_LEVEL_ERROR && (
{domain.domainException.exception}
diff --git a/packages/components/src/internal/components/domainproperties/constants.ts b/packages/components/src/internal/components/domainproperties/constants.ts index a914003a0a..7525f0dce1 100644 --- a/packages/components/src/internal/components/domainproperties/constants.ts +++ b/packages/components/src/internal/components/domainproperties/constants.ts @@ -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; diff --git a/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx b/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx index 81a6077c80..15c7885e52 100644 --- a/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx +++ b/packages/components/src/internal/components/domainproperties/samples/SampleTypeDesigner.tsx @@ -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, @@ -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; @@ -138,7 +138,7 @@ interface State { uniqueIdsConfirmed: boolean; } // Exported for testing -export class SampleTypeDesignerImpl extends React.PureComponent { +export class SampleTypeDesignerImpl extends React.PureComponent { static defaultProps = { api: getDefaultAPIWrapper(), defaultSampleFieldConfig: DEFAULT_SAMPLE_FIELD_CONFIG, @@ -156,7 +156,7 @@ export class SampleTypeDesignerImpl extends React.PureComponent; + return ; }; getNumNewUniqueIdFields(): number { @@ -558,7 +558,7 @@ export class SampleTypeDesignerImpl extends React.PureComponent field.isNew() && field.isUniqueIdField()).count(); } - getDomainDetails = (): { [key: string]: any } => { + getDomainDetails = (): Record => { const { model } = this.state; const { @@ -683,56 +683,31 @@ export class SampleTypeDesignerImpl extends React.PureComponent - + {appPropertiesOnly && allowFolderExclusion && ( // appPropertiesOnly check will prevent this panel from showing in LKS and in LKB media types )} {error &&
{error && {error}}
} {showUniqueIdConfirmation && ( {confirmModalMessage} )} ); diff --git a/packages/components/src/internal/components/editable/Cell.tsx b/packages/components/src/internal/components/editable/Cell.tsx index 95839e694c..3f3e2f0c15 100644 --- a/packages/components/src/internal/components/editable/Cell.tsx +++ b/packages/components/src/internal/components/editable/Cell.tsx @@ -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]; @@ -532,6 +533,8 @@ export class Cell extends React.PureComponent { .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*/); + return ( <> { placeholder={placeholder} selected={selected} selection={selection} - showMenu={showLookup || !!col.inputRenderer} + showMenu={showMenu} targetRef={this.displayEl} /> {renderDragHandle && !this.isReadOnly && ( diff --git a/packages/components/src/internal/components/editable/utils.test.ts b/packages/components/src/internal/components/editable/utils.test.ts index c10dfcb870..8895584578 100644 --- a/packages/components/src/internal/components/editable/utils.test.ts +++ b/packages/components/src/internal/components/editable/utils.test.ts @@ -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'; @@ -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' }); diff --git a/packages/components/src/internal/components/editable/utils.ts b/packages/components/src/internal/components/editable/utils.ts index 957ebed635..b5c1ee6f13 100644 --- a/packages/components/src/internal/components/editable/utils.ts +++ b/packages/components/src/internal/components/editable/utils.ts @@ -20,6 +20,7 @@ import { incrementClientSideMetricCount } from '../../actions'; import { CellMessage } from './models'; import { CellActions, MODIFICATION_TYPES } from './constants'; +import { NON_NEGATIVE_NUMBER_CONCEPT_URI } from '../domainproperties/constants'; interface ValidatedValue { message: CellMessage; @@ -63,6 +64,8 @@ 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 (jsonType === 'string' && scale) { if (value.toString().trim().length > scale) message = value.toString().trim().length + '/' + scale + ' characters'; diff --git a/packages/components/src/internal/components/forms/QueryFormInputs.tsx b/packages/components/src/internal/components/forms/QueryFormInputs.tsx index 7ae2f034b7..152f44a6bb 100644 --- a/packages/components/src/internal/components/forms/QueryFormInputs.tsx +++ b/packages/components/src/internal/components/forms/QueryFormInputs.tsx @@ -194,7 +194,7 @@ export class QueryFormInputs extends React.Component filter(col)) + .filter(col => filter(col) && !col.removeFromFormInput) .map(col => { const { fieldKey, name, required } = col; const shouldDisableField = initiallyDisableFields || disabledFields.contains(name.toLowerCase()); @@ -223,8 +223,10 @@ export class QueryFormInputs extends React.Component ReactNode; @@ -294,7 +294,10 @@ export function resolveDetailEditRenderer( } let value = resolveDetailFieldValue(data); - const ColumnInputRenderer = resolveInputRenderer(col); + const ColumnInputRenderer = + col.conceptURI === NON_NEGATIVE_NUMBER_CONCEPT_URI + ? null /* Use standard forminput for media amount/unit */ + : resolveInputRenderer(col); if (ColumnInputRenderer) { return ( { + const amountCol = { name: 'StoredAmount', caption: 'amount', fieldKey: 'amountKey' }; + const unitCol = { + name: 'Units', + caption: 'unit', + fieldKey: 'unitKey', + lookup: { + hasQueryFilters: jest.fn(), + displayColumn: new QueryColumn({ caption: 'test' }), + }, + }; + const data = { StoredAmount: 12.5, Units: 'mg' }; + const allColumns = new ExtendedMap({ + [amountCol.fieldKey]: amountCol, + [unitCol.fieldKey]: unitCol, + }); + + const CAN_DISABLE: any = { + allowFieldDisable: true, + onSelectChange: jest.fn(), + onToggleDisable: jest.fn(), + initiallyDisabled: false, + containerFilter: undefined, + containerPath: undefined, + allColumns, + data, + queryFilters: {}, + }; + + const DISABLED: any = { + ...CAN_DISABLE, + initiallyDisabled: true, + }; + + const NOT_DISABLABLE: any = { + ...CAN_DISABLE, + allowFieldDisable: false, + }; + + test('returns null when required columns are missing', () => { + // Missing unit column + + const someColumns = new ExtendedMap({ [amountCol.fieldKey]: amountCol }); + const { container } = render( + + + + ); + + // Component should render nothing when unit or amount column can't be found + expect(container.querySelector('.inner-test')).toBeEmptyDOMElement(); + }); + + test('with amount and unit column, can disable', () => { + const { container } = render( + + + + ); + expect(document.querySelectorAll('.form-group.row')).toHaveLength(1); + expect(document.querySelectorAll('label')).toHaveLength(2); + expect(document.querySelectorAll('label')[0].textContent).toBe('Amount and Units '); + expect(document.querySelectorAll('label')[1].textContent).toBe(''); + expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(1); + expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(0); + const inputs = document.querySelectorAll('input'); + expect(inputs).toHaveLength(4); + expect(inputs[0].getAttribute('value')).toBe('true'); + expect(inputs[0].getAttribute('type')).toBe('hidden'); + expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled'); + expect(inputs[1].getAttribute('value')).toBe('12.5'); + expect(inputs[1].getAttribute('name')).toBe('amountKey'); + expect(inputs[2].getAttribute('role')).toBe('combobox'); + expect(inputs[3].getAttribute('name')).toBe('Units::enabled'); + expect(inputs[3].getAttribute('value')).toBe('true'); + expect(inputs[3].getAttribute('type')).toBe('hidden'); + }); + + test('with amount and unit column, can disable and disabled', () => { + const { container } = render( + + + + ); + expect(document.querySelectorAll('.form-group.row')).toHaveLength(1); + expect(document.querySelectorAll('label')).toHaveLength(2); + expect(document.querySelectorAll('label')[0].textContent).toBe('Amount and Units '); + expect(document.querySelectorAll('label')[1].textContent).toBe(''); + expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0); + expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(1); + const inputs = document.querySelectorAll('input'); + expect(inputs).toHaveLength(4); + expect(inputs[0].getAttribute('value')).toBe('false'); + expect(inputs[0].getAttribute('type')).toBe('hidden'); + expect(inputs[0].getAttribute('name')).toBe('StoredAmount::enabled'); + expect(inputs[1].getAttribute('value')).toBe('12.5'); + expect(inputs[1].getAttribute('name')).toBe('amountKey'); + expect(inputs[2].getAttribute('role')).toBe('combobox'); + expect(inputs[3].getAttribute('name')).toBe('Units::enabled'); + expect(inputs[3].getAttribute('value')).toBe('false'); + expect(inputs[3].getAttribute('type')).toBe('hidden'); + }); + + test('with amount and unit column, cannot disable', () => { + const { container } = render( + + + + ); + expect(document.querySelectorAll('.form-group.row')).toHaveLength(1); + expect(document.querySelectorAll('label')).toHaveLength(2); + expect(document.querySelectorAll('label')[0].textContent).toBe('Amount and Units '); + expect(document.querySelectorAll('label')[1].textContent).toBe(''); + expect(document.querySelectorAll('.fa-toggle-on')).toHaveLength(0); + expect(document.querySelectorAll('.fa-toggle-off')).toHaveLength(0); + const inputs = document.querySelectorAll('input'); + expect(inputs).toHaveLength(2); + expect(inputs[0].getAttribute('value')).toBe('12.5'); + expect(inputs[0].getAttribute('name')).toBe('amountKey'); + expect(inputs[1].getAttribute('role')).toBe('combobox'); + }); +}); diff --git a/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx new file mode 100644 index 0000000000..06797ffe05 --- /dev/null +++ b/packages/components/src/internal/components/forms/input/AmountUnitInput.tsx @@ -0,0 +1,102 @@ +import React, { FC, memo, useCallback, useMemo, useState } from 'react'; +import { List } from 'immutable'; + +import { TextInput } from './TextInput'; +import { QuerySelect } from '../QuerySelect'; +import { getContainerFilterForLookups } from '../../../query/api'; +import { FieldLabel } from '../FieldLabel'; +import { InputRendererProps } from './types'; +import { caseInsensitive, generateId } from '../../../util/utils'; +import { FormsyInput } from './FormsyReactComponents'; +import { Operation } from '../../../../public/QueryColumn'; +import { STORED_AMOUNT_FIELDS } from '../../samples/constants'; + +export const AmountUnitInput: FC = memo(props => { + const { + allowFieldDisable, + onSelectChange, + onToggleDisable, + initiallyDisabled, + containerFilter, + containerPath, + allColumns, + data, + queryFilters, + } = props; + const [disabled, setDisabled] = useState(initiallyDisabled && allowFieldDisable); + + 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()); + const amountValue = caseInsensitive(data, amountCol?.name); + const unitValue = caseInsensitive(data, unitCol?.name); + const queryFilter = unitCol?.lookup.hasQueryFilters(Operation.insert) + ? List(unitCol?.lookup.getQueryFilters(Operation.insert)) + : queryFilters?.[unitCol?.fieldKey]; + + const id = useMemo(() => generateId('amount-unit-input-'), []); + + const onToggleChange = useCallback(() => { + setDisabled(prevDisabled => { + const newDisabled = !prevDisabled; + onToggleDisable?.(newDisabled); + return newDisabled; + }); + }, [setDisabled]); + + if (!amountCol || !unitCol) { + return null; + } + + return ( +
+ + + + {allowFieldDisable && ( + + )} +
+ ); +}); + +AmountUnitInput.displayName = 'AmountUnitInput'; diff --git a/packages/components/src/internal/components/forms/input/InputRenderFactory.ts b/packages/components/src/internal/components/forms/input/InputRenderFactory.ts index fd69a13e4e..68945769b6 100644 --- a/packages/components/src/internal/components/forms/input/InputRenderFactory.ts +++ b/packages/components/src/internal/components/forms/input/InputRenderFactory.ts @@ -6,6 +6,7 @@ import { InputRendererProps } from './types'; import { AliasGridInput, AliasInput } from './AliasInput'; import { AppendUnitsInput } from './AppendUnitsInput'; import { SampleStatusInputRenderer } from './SampleStatusInput'; +import { AmountUnitInput } from './AmountUnitInput'; export type InputRendererComponent = ComponentType; export type InputRendererFactory = (col: QueryColumn, isGridInput?: boolean) => InputRendererComponent; @@ -48,4 +49,5 @@ export function registerInputRenderers(): void { registerInputRenderer('ExperimentAlias', AliasGridInput, InputRenderContext.Grid); registerInputRenderer('ExperimentAlias', AliasInput, InputRenderContext.Form); registerInputRenderer('SampleStatusInput', SampleStatusInputRenderer); + registerInputRenderer('AmountUnitInput', AmountUnitInput, InputRenderContext.Form); } diff --git a/packages/components/src/internal/components/forms/input/SelectInput.tsx b/packages/components/src/internal/components/forms/input/SelectInput.tsx index 9e1ef55381..5003879301 100644 --- a/packages/components/src/internal/components/forms/input/SelectInput.tsx +++ b/packages/components/src/internal/components/forms/input/SelectInput.tsx @@ -145,7 +145,7 @@ export type SelectInputChange = ( // Copied from @types/react-select/src/Select.d.ts export type FilterOption = ((option: SelectInputOption, rawInput: string) => boolean) | null; -function initOptionFromPrimitive(value: string | number, props: SelectInputProps): SelectInputOption { +function initOptionFromPrimitive(value: number | string, props: SelectInputProps): SelectInputOption { const { labelKey = 'label', options, valueKey = 'value' } = props; const result = options?.find(o => o[valueKey] === value); if (result) return result; @@ -205,8 +205,8 @@ export interface SelectInputProps { autoValue?: boolean; backspaceRemovesValue?: boolean; cacheOptions?: boolean; - clearCacheOnChange?: boolean; clearable?: boolean; + clearCacheOnChange?: boolean; closeMenuOnSelect?: boolean; containerClass?: string; customStyles?: Record; @@ -216,6 +216,7 @@ export interface SelectInputProps { delimiter?: string; description?: string; disabled?: boolean; + disableInput?: boolean; filterOption?: FilterOption; formatCreateLabel?: (inputValue: string) => ReactNode; formatGroupLabel?: (data: any) => ReactNode; @@ -269,7 +270,7 @@ export interface SelectInputProps { warning?: ReactNode; } -type SelectInputImplProps = SelectInputProps & FormsyInjectedProps; +type SelectInputImplProps = FormsyInjectedProps & SelectInputProps; interface State { // This state property is used in conjunction with the prop "clearCacheOnChange" which when true @@ -537,8 +538,9 @@ export class SelectInputImpl extends Component { return ( { }} showLabel={showLabel} showToggle={allowDisable} - isDisabled={isDisabled} toggleProps={{ onClick: toggleDisabledTooltip ? undefined : this.onToggleChange, toolTip: toggleDisabledTooltip, @@ -686,7 +687,7 @@ export class SelectInputImpl extends Component { id: this.getId(), inputId, isClearable: clearable, - isDisabled: disabled || this.state.isDisabled, + isDisabled: disabled || this.state.isDisabled || this.props.disableInput, isLoading, isMulti: multiple, isValidNewOption, diff --git a/packages/components/src/internal/components/forms/input/TextInput.tsx b/packages/components/src/internal/components/forms/input/TextInput.tsx index 8d081a7c8f..a0e136a032 100644 --- a/packages/components/src/internal/components/forms/input/TextInput.tsx +++ b/packages/components/src/internal/components/forms/input/TextInput.tsx @@ -27,6 +27,7 @@ import { InternalSpacesWarning } from '../InternalSpacesWarning'; export interface TextInputProps extends DisableableInputProps, Omit { addLabelAsterisk?: boolean; + disableInput?: boolean; includeSpacesWarning?: boolean; isUpdate?: boolean; onChange?: (value: any) => void; @@ -91,12 +92,12 @@ export class TextInput extends DisableableInput return ( ...inputProps } = rest; - let help: string; // Issue 52367: Don't show the message if we have a name that can be edited if (queryColumn.nameExpression && !isUpdate) { @@ -142,7 +142,7 @@ export class TextInput extends DisableableInput required={queryColumn.required} {...inputProps} componentRef={this.textInput} - disabled={this.state.isDisabled} + disabled={this.state.isDisabled || this.props.disableInput} help={help} label={this.renderLabel()} labelClassName={showLabel ? labelClassName : 'hide-label'} diff --git a/packages/components/src/internal/components/forms/input/types.ts b/packages/components/src/internal/components/forms/input/types.ts index 7a3abedf33..abfe2a7200 100644 --- a/packages/components/src/internal/components/forms/input/types.ts +++ b/packages/components/src/internal/components/forms/input/types.ts @@ -1,12 +1,15 @@ import { ReactNode } from 'react'; +import { List } from 'immutable'; -import { Query } from '@labkey/api'; +import { Filter, Query } from '@labkey/api'; import { QueryColumn } from '../../../../public/QueryColumn'; import { SelectInputChange, SelectInputProps } from './SelectInput'; +import { ExtendedMap } from '../../../../public/ExtendedMap'; export interface InputRendererProps { + allColumns?: ExtendedMap; allowFieldDisable?: boolean; col: QueryColumn; containerFilter?: Query.ContainerFilter; @@ -19,6 +22,7 @@ export interface InputRendererProps { onAdditionalFormDataChange?: (name: string, value: any) => void; onSelectChange?: SelectInputChange; onToggleDisable?: (disabled: boolean) => void; + queryFilters?: Record>; renderLabelField?: (col: QueryColumn) => ReactNode; selectInputProps?: Omit; showAsteriskSymbol?: boolean; diff --git a/packages/components/src/internal/query/api.ts b/packages/components/src/internal/query/api.ts index 47a8768d0a..45a210c4e3 100644 --- a/packages/components/src/internal/query/api.ts +++ b/packages/components/src/internal/query/api.ts @@ -373,6 +373,10 @@ function applyViewColumns( export class Renderers { static _check(columnMetadata, rawColumn, field, metadata) { + const columnValue = columnMetadata[field]; + if (columnValue) + return columnValue; + if (columnMetadata.conceptURI || rawColumn.conceptURI) { const concept = metadata.getIn([ 'concepts', diff --git a/packages/components/src/public/QueryColumn.ts b/packages/components/src/public/QueryColumn.ts index 1370b73d47..fc77622bef 100644 --- a/packages/components/src/public/QueryColumn.ts +++ b/packages/components/src/public/QueryColumn.ts @@ -190,6 +190,7 @@ export class QueryColumn implements IQueryColumn { declare inputRenderer: string; declare sorts: '+' | '-'; declare removeFromViews: boolean; // strips this column from all ViewInfo definitions + declare removeFromFormInput: boolean; // strips this column from QueryFormInputs declare units: string; declare derivationDataScope: string;