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.40.1",
"version": "6.40.2",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
4 changes: 4 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version 6.40.2
*Released*: 6 May 2025
- Issue 52773: display warning for unresolved form lookup values

### version 6.40.1
*Released*: 6 May 2025
- Issue 52556: Add data-fieldkey attribute to grid header elements and input elements
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import {
changeColumn,
detectPadLength,
loadEditorModelData,
lookupValidationError,
parseIntIfNumber,
parsePastedLookup,
removeColumn,
Expand Down Expand Up @@ -1319,27 +1318,3 @@ describe('loadEditorModelData', () => {
expect(api.query.selectRows).toHaveBeenCalledTimes(2);
});
});

describe('lookupValidationError', () => {
test('value only', () => {
expect(lookupValidationError('s').message).toEqual('Could not find s. Data may have been moved or deleted.');
expect(lookupValidationError(1.4).message).toEqual('Could not find 1.4. Data may have been moved or deleted.');
expect(lookupValidationError(false).message).toEqual(
'Could not find false. Data may have been moved or deleted.'
);
});

test('fromPaste', () => {
expect(lookupValidationError(false, true).message).toEqual('Could not find false');
expect(lookupValidationError('beep', true).message).toEqual('Could not find beep');
expect(lookupValidationError('"sara", "pete"', true).message).toEqual(
'Could not find "sara", "pete". Please make sure values that contain commas are properly quoted.'
);
});

test('with displayValue', () => {
expect(lookupValidationError('beep,', false, 'vw').message).toEqual(
'vw is no longer a valid value. Data may have been moved or deleted.'
);
});
});
26 changes: 4 additions & 22 deletions packages/components/src/internal/components/editable/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { getContainerFilterForLookups } from '../../query/api';

import { ComponentsAPIWrapper, getDefaultAPIWrapper } from '../../APIWrapper';

import { resolveErrorMessage } from '../../util/messaging';
import { lookupValidationErrorMessage, resolveErrorMessage } from '../../util/messaging';

import {
CellMessage,
Expand Down Expand Up @@ -396,7 +396,7 @@ async function getLookupValueDescriptors(
messageAndValues.push({
message: {
isWarning: true,
message: errorMap[value] ?? lookupValidationError(value, false, displayValue).message,
message: errorMap[value] ?? lookupValidationErrorMessage(value, false, displayValue),
},
valueDescriptor: { display: displayValue ?? `<${value}>`, raw: value },
});
Expand All @@ -410,24 +410,6 @@ async function getLookupValueDescriptors(
return lookupValues;
}

export function lookupValidationError(
value: string | number | boolean,
fromPaste?: boolean,
displayValue?: any
): CellMessage {
let message = displayValue !== undefined ? `${displayValue} is no longer a valid value` : `Could not find ${value}`;

if (fromPaste) {
if (typeof value === 'string' && value.toString().indexOf(',') > -1) {
message += '. Please make sure values that contain commas are properly quoted.';
}
} else {
message += '. Data may have been moved or deleted.';
}

return { message };
}

async function getLookupDisplayValue(column: QueryColumn, value: any, containerPath: string): Promise<MessageAndValue> {
if (value === undefined || value === null) {
return {
Expand All @@ -442,7 +424,7 @@ async function getLookupDisplayValue(column: QueryColumn, value: any, containerP

const descriptors = await findLookupValues({ column, containerPath, forUpdate: false, lookupKeyValues: [value] });
if (!descriptors.length) {
message = lookupValidationError(value);
message = { message: lookupValidationErrorMessage(value) };
}

return {
Expand Down Expand Up @@ -1100,7 +1082,7 @@ export function parsePastedLookup(
.slice(0, 4)
.map(u => '"' + u + '"')
.join(', ');
message = lookupValidationError(valueStr, true);
message = { message: lookupValidationErrorMessage(valueStr, true) };
}

return {
Expand Down
22 changes: 20 additions & 2 deletions packages/components/src/internal/components/forms/QuerySelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType, FC, memo, useCallback, useEffect, useState } from 'react';
import React, { ComponentType, FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { List, Map } from 'immutable';
import { Filter, Query, Utils } from '@labkey/api';

import { SchemaQuery } from '../../../public/SchemaQuery';

import { resolveErrorMessage } from '../../util/messaging';
import { lookupValidationErrorMessage, resolveErrorMessage } from '../../util/messaging';

import { Row } from '../../query/selectRows';

Expand Down Expand Up @@ -263,6 +263,7 @@ export const QuerySelect: FC<QuerySelectOwnProps> = memo(props => {
const [searches, setSearches] = useState<Search[]>([]);
const debounceTO = useTimeout();
const shouldLoadOnFocus = loadOnFocus && !loadOnFocusLock;
const hasNotFoundValues = model.notFoundValues?.size > 0;

useEffect(() => {
if (!autoInit) return;
Expand Down Expand Up @@ -399,6 +400,20 @@ export const QuerySelect: FC<QuerySelectOwnProps> = memo(props => {
[OptionComponent, model]
);

// Issue 52773: If a value is specified, but we are unable to resolve the value then display a warning to the user.
const warning = useMemo(() => {
if (!hasNotFoundValues) return undefined;

let warningValue: string;
if (model.notFoundValues.size < 5) {
warningValue = model.notFoundValues.join(', ');
} else {
warningValue = `${model.notFoundValues.size} values`;
}

return lookupValidationErrorMessage(warningValue);
}, [hasNotFoundValues, model.notFoundValues]);

if (error) {
return (
<SelectInput
Expand Down Expand Up @@ -443,8 +458,11 @@ export const QuerySelect: FC<QuerySelectOwnProps> = memo(props => {
onFocus={onFocus}
optionRenderer={optionRenderer}
options={undefined} // prevent override
// Issue 52773: Allow for submission of required fields whose value is not found
required={hasNotFoundValues ? false : required}
selectedOptions={model.isInit ? model.selectedOptions : undefined}
value={getValue(model, multiple)} // needed to initialize the Formsy "value" properly
warning={warning}
/>
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ReactSelect, { components } from 'react-select';
import AsyncSelect from 'react-select/async';
import AsyncCreatableSelect from 'react-select/async-creatable';
import CreatableSelect from 'react-select/creatable';
import classNames from 'classnames';
import { Utils } from '@labkey/api';

import { FieldLabel } from '../FieldLabel';
Expand Down Expand Up @@ -237,6 +238,7 @@ export interface SelectInputProps {
value?: any;
valueKey?: string;
valueRenderer?: any;
warning?: ReactNode;
}

type SelectInputImplProps = SelectInputProps & FormsyInjectedProps<any>;
Expand Down Expand Up @@ -713,20 +715,22 @@ export class SelectInputImpl extends Component<SelectInputImplProps, State> {
};

render() {
const { containerClass, errorMessage, formsy, help, inputClass } = this.props;
const { containerClass, errorMessage, formsy, help, inputClass, warning } = this.props;
const hasError = formsy && !!errorMessage;
const hasWarning = !hasError && !!warning;

const className = classNames('select-input-container', containerClass, {
'has-error': hasError,
'has-warning': hasWarning,
});

return (
<div className={`select-input-container ${containerClass}`}>
<div className={className}>
{this.renderLabel()}
<div className={inputClass}>
{this.renderSelect()}
{hasError && (
<div className="has-error">
<span className="error-message help-block">{errorMessage}</span>
</div>
)}
{!hasError && !!help && <span className="help-block">{help}</span>}
{hasError && <span className="help-block">{errorMessage}</span>}
{!hasError && (hasWarning || !!help) && <span className="help-block">{warning ?? help}</span>}
</div>
</div>
);
Expand Down
115 changes: 114 additions & 1 deletion packages/components/src/internal/components/forms/model.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { fromJS } from 'immutable';
import { Filter } from '@labkey/api';

import { QueryInfo } from '../../../public/QueryInfo';
import { ExtendedMap } from '../../../public/ExtendedMap';
import { QueryColumn } from '../../../public/QueryColumn';

import { parseSelectedQuery, QuerySelectModel, queryColumnNames } from './model';
import { buildValueFilter, findNotFoundValues, parseSelectedQuery, QuerySelectModel, queryColumnNames } from './model';

describe('form actions', () => {
const setSelectionModel = new QuerySelectModel({
Expand Down Expand Up @@ -92,4 +93,116 @@ describe('form actions', () => {
).sort()
).toEqual(['colA', displayColumn, groupByColumn, 'pkCol1', 'pkCol2', valueColumn, someRequiredColumn]);
});

describe('buildValueFilter', () => {
const col = 'id';
const delimiter = ',';

test('handles single non-array, non-string value with multiple = false', () => {
const result = buildValueFilter('abc', col, false, delimiter);
expect(result.expectedValueCount).toBe(1);
expect(result.filter.getValue()).toBe('abc');
expect(result.filter.getColumnName()).toBe(col);
});

test('handles array input when multiple = true', () => {
const result = buildValueFilter(['a', 'b', 'b'], col, true, delimiter);
expect(result.expectedValueCount).toBe(2); // unique values
expect(result.filter.getValue()).toEqual(['a', 'b', 'b']);
expect(result.filter.getColumnName()).toBe(col);
});

test('handles delimited string input when multiple = true', () => {
const result = buildValueFilter('x,y,y,z', col, true, ',');
expect(result.expectedValueCount).toBe(3);
expect(result.filter.getValue()).toEqual(['x', 'y', 'y', 'z']);
});

test('handles string value when multiple = false', () => {
const result = buildValueFilter('foo', col, false, delimiter);
expect(result.expectedValueCount).toBe(1);
expect(result.filter.getValue()).toBe('foo');
});

test('handles non-string scalar values correctly', () => {
const result = buildValueFilter(123, col, false, delimiter);
expect(result.expectedValueCount).toBe(1);
expect(result.filter.getValue()).toBe(123);
});

test('defaults to non-IN filter if multiple = true but value is not string or array', () => {
const result = buildValueFilter(true, col, true, delimiter);
expect(result.expectedValueCount).toBe(1);
expect(result.filter.getValue()).toBe(true);
});
});

describe('findNotFoundValues', () => {
const EMPTY_ITEMS = {};

function filter(value: any): Filter.IFilter {
return Filter.create('col', value, Filter.Types.IN);
}

test('returns empty array when filter value is undefined or null', () => {
expect(findNotFoundValues(EMPTY_ITEMS, filter(undefined), 'id')).toHaveLength(0);
expect(findNotFoundValues(EMPTY_ITEMS, filter(null), 'id')).toHaveLength(0);
expect(findNotFoundValues(EMPTY_ITEMS, filter([undefined, null]), 'id')).toHaveLength(0);
});

test('returns single value as array when filter has a single value', () => {
expect(findNotFoundValues(EMPTY_ITEMS, filter('abc'), 'id')).toEqual(['abc']);
expect(findNotFoundValues(EMPTY_ITEMS, filter(['abc']), 'id')).toEqual(['abc']);
});

test('returns all values when no items are selected', () => {
const filterValues = ['x', 'y', 'z'];
expect(findNotFoundValues(EMPTY_ITEMS, filter(filterValues), 'id')).toEqual(filterValues);
});

test('returns missing values when some items are matched', () => {
const selectedItems = { one: { id: { value: 'x' } }, two: { id: { value: 'z' } } };
expect(findNotFoundValues(selectedItems, filter(['x', 'y', 'z']), 'id')).toEqual(['y']);
});

test('returns empty array when all values are found in selected items', () => {
const selectedAll = {
one: { id: { value: 'x' } },
two: { id: { value: 'y' } },
three: { id: { value: 'z' } },
};
expect(findNotFoundValues(selectedAll, filter(['x', 'y', 'z']), 'id')).toEqual([]);
});

test('handles mixed types and converts to string for comparison', () => {
const mixedItems = { one: { id: { value: 1 } }, two: { id: { value: 3 } } };
expect(findNotFoundValues(mixedItems, filter([1, 2, 3]), 'id')).toEqual(['2']);
});

test('ignores items missing the valueColumn', () => {
const incompleteItems = {
one: { id: { value: 'x' } },
two: { other: { value: 'y' } }, // missing 'id'
};
expect(findNotFoundValues(incompleteItems, filter(['x', 'y']), 'id')).toEqual(['y']);
});

test('ignores null or undefined item values', () => {
const nullItemValues = {
one: { id: { value: 'x' } },
two: { id: { value: null } },
three: { id: { value: undefined } },
};
expect(findNotFoundValues(nullItemValues, filter(['x', 'y']), 'id')).toEqual(['y']);
});

test('handles duplicate values in filter input', () => {
expect(findNotFoundValues(EMPTY_ITEMS, filter(['a', 'a', 'b']), 'id')).toEqual(['a', 'b']);
});

test('handles mixed string/number types across filters and item values', () => {
const mixedTypes = { one: { id: { value: '1' } }, two: { id: { value: 2 } } };
expect(findNotFoundValues(mixedTypes, filter([1, 2, 3]), 'id')).toEqual(['3']);
});
});
});
Loading