Skip to content
Open
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": "7.23.2",
"version": "7.23.3-fb-mvtc-bash2.1",
"description": "Components, models, actions, and utility functions for LabKey applications and pages",
"sideEffects": false,
"files": [
Expand Down
8 changes: 8 additions & 0 deletions packages/components/releaseNotes/components.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# @labkey/components
Components, models, actions, and utility functions for LabKey applications and pages

### version 7.X
*Released*: X March 2026
- GitHub Issue 916: Copying/pasting in the grid doesn't always act as expected
- GitHub Issue 942: Add error for duplicate values for MVTC fields
- GitHub Issue 961: Clicking the "Allow multiple selections" label doesn't toggle the checkbox
- GitHub Issue 932: No help text for disabled Multi-Value checkbox in designer
- GitHub Issue 917: Copying/pasting in the grid doesn't reorder selection

### version 7.23.2
*Released*: 18 March 2026
- Merge from release26.3-SNAPSHOT to develop
Expand Down
5 changes: 5 additions & 0 deletions packages/components/src/internal/OverlayTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ interface Props extends PropsWithChildren {
overlay: ReactElement<OverlayComponent>; // See note in doc string below
style?: CSSProperties;
triggerType?: TriggerType;
noShow?: boolean;
}

/**
Expand All @@ -155,6 +156,7 @@ export const OverlayTrigger: FC<Props> = ({
overlay,
triggerType = 'hover',
style,
noShow,
}) => {
const id_ = useMemo(() => id ?? generateId(), [id]);
const { onMouseEnter, onMouseLeave, onClick, portalEl, show, targetRef } = useOverlayTriggerState(
Expand All @@ -167,6 +169,9 @@ export const OverlayTrigger: FC<Props> = ({
const className_ = classNames('overlay-trigger', className);
const clonedContent = cloneElement(overlay, { targetRef });

if (noShow)
return children;

return (
<div
className={className_}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,6 @@ describe('TextChoiceOptions', () => {
const multiCheckbox = document.querySelector('input.domain-text-choice-multi') as HTMLInputElement;
expect(multiCheckbox).toBeInTheDocument();
expect(multiCheckbox).toBeDisabled();
const labelSpan = screen.getByText('Allow multiple selections');
expect(labelSpan.getAttribute('title')).toBe('Multiple values are currently used by at least one data row.');
});

test('multi-choice checkbox not present', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import { getTextChoiceInUseValues, TextChoiceInUseValues } from './actions';
import { createFormInputId } from './utils';
import { isFieldFullyLocked } from './propertiesUtil';
import { MULTI_CHOICE_TYPE, TEXT_CHOICE_TYPE } from './PropDescType';
import { Popover } from '../../Popover';
import { OverlayTrigger } from '../../OverlayTrigger';

const MIN_VALUES_FOR_SEARCH_COUNT = 2;
const HELP_TIP_BODY = <p>The set of values to be used as drop-down options to restrict data entry into this field.</p>;
Expand Down Expand Up @@ -100,6 +102,7 @@ export const TextChoiceOptionsImpl: FC<ImplProps> = memo(props => {
const [showAddValuesModal, setShowAddValuesModal] = useState<boolean>();
const [search, setSearch] = useState<string>('');
const fieldTypeId = createFormInputId(DOMAIN_FIELD_TYPE, domainIndex, index);
const mvPopOverId = useMemo(() => createFormInputId('mv-in-use-popover', domainIndex, index), [domainIndex, index]);
const isMultiChoiceField = field.dataType.name === MULTI_CHOICE_TYPE.name;

// keep a map from the updated values for the in-use field values to their original values
Expand Down Expand Up @@ -280,25 +283,26 @@ export const TextChoiceOptionsImpl: FC<ImplProps> = memo(props => {
title={`Add Values (max ${maxValueCount})`}
/>
{allowMultiChoice && (
<>
<input
checked={field.dataType.name === 'multiChoice'}
className="domain-text-choice-multi"
disabled={isFieldFullyLocked(field.lockType) || hasMultiValueInUse}
id={createFormInputId(DOMAIN_FIELD_TEXTCHOICE_MULTI, domainIndex, index)}
onChange={onAllowMultiChange}
type="checkbox"
/>
<span
title={
hasMultiValueInUse
? 'Multiple values are currently used by at least one data row.'
: ''
}
>
Allow multiple selections
</span>
</>
<OverlayTrigger
noShow={!hasMultiValueInUse}
overlay={
<Popover id={mvPopOverId} placement="top">
Multiple values are currently used by at least one data row.
</Popover>
}
>
<label id={mvPopOverId}>
<input
checked={field.dataType.name === 'multiChoice'}
className="domain-text-choice-multi"
disabled={isFieldFullyLocked(field.lockType) || hasMultiValueInUse}
id={createFormInputId(DOMAIN_FIELD_TEXTCHOICE_MULTI, domainIndex, index)}
onChange={onAllowMultiChange}
type="checkbox"
/>
<span>Allow multiple selections</span>
</label>
</OverlayTrigger>
)}
</div>
<div className="col-xs-6 col-lg-4">
Expand Down
196 changes: 195 additions & 1 deletion packages/components/src/internal/components/editable/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@ describe('insertPastedData', () => {
fieldKey: mvtc,
jsonType: 'ARRAY',
rangeURI: MULTI_CHOICE_TYPE.rangeURI,
validValues: ['a', 'ab', 'cc', 'cD', 'A,B', 'de'],
validValues: ['a', 'ab', 'cc', 'cD', 'A,B', 'de', 'A', 'B'],
}),
},
});
Expand Down Expand Up @@ -1175,6 +1175,200 @@ describe('insertPastedData', () => {
expect(cellMessages.get(genCellKey(mvtc, 1))).toBeUndefined();
expect(cellMessages.get(genCellKey(mvtc, 2))).toEqual({ message: 'Could not find "bad"' });
});

test('pasting string values with special characters, fromDragFill false', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [genCellKey(fkOne, 0), genCellKey(fkOne, 1), genCellKey(fkOne, 2)],
selectedColIdx: 0,
selectedRowIdx: 2,
});
const changes = await validateAndInsertPastedData(
em,
'hello world\n"hello, world"\n"say ""hello"""',
undefined,
true,
true,
undefined,
true
);
const cellValues = changes.cellValues;
// Space is preserved as-is
expect(cellValues.get(genCellKey(fkOne, 0))).toEqual(List([{ display: 'hello world', raw: 'hello world' }]));
// Quoted comma: without fromDragFill, CSV quoting is NOT stripped
expect(cellValues.get(genCellKey(fkOne, 1))).toEqual(
List([{ display: '"hello, world"', raw: '"hello, world"' }])
);
// Escaped double quotes: without fromDragFill, CSV escaping is NOT processed
expect(cellValues.get(genCellKey(fkOne, 2))).toEqual(
List([{ display: '"say ""hello"""', raw: '"say ""hello"""' }])
);
});

test('drag fill string values with special characters, fromDragFill true', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [
genCellKey(fkOne, 0),
genCellKey(fkOne, 1),
genCellKey(fkOne, 2),
genCellKey(fkOne, 3),
genCellKey(fkOne, 4),
genCellKey(fkOne, 5),
],
selectedColIdx: 0,
selectedRowIdx: 5,
});
const changes = await validateAndInsertPastedData(
em,
'hello world\n"hello, world"\n"say ""hello"""',
undefined,
true,
true,
undefined,
false,
[[genCellKey(fkOne, 3), genCellKey(fkOne, 4), genCellKey(fkOne, 5)]],
true
);
const cellValues = changes.cellValues;
// Original values unchanged
expect(cellValues.get(genCellKey(fkOne, 0))).toEqual(List([{ display: 'qwer', raw: 'qwer' }]));
expect(cellValues.get(genCellKey(fkOne, 1))).toEqual(List([{ display: 'asdf', raw: 'asdf' }]));
expect(cellValues.get(genCellKey(fkOne, 2))).toEqual(List([{ display: 'zxcv', raw: 'zxcv' }]));
// Space: no CSV quoting to strip
expect(cellValues.get(genCellKey(fkOne, 3))).toEqual(List([{ display: 'hello world', raw: 'hello world' }]));
// Quoted comma: fromDragFill strips CSV quoting, comma preserved in value
expect(cellValues.get(genCellKey(fkOne, 4))).toEqual(List([{ display: 'hello, world', raw: 'hello, world' }]));
// Escaped double quotes: fromDragFill strips CSV quoting and unescapes ""
expect(cellValues.get(genCellKey(fkOne, 5))).toEqual(List([{ display: 'say "hello"', raw: 'say "hello"' }]));
});

test('pasting exactly A,B into mvtc matches single valid value', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [genCellKey(mvtc, 0)],
selectedColIdx: 3,
selectedRowIdx: 0,
});
const changes = await validateAndInsertPastedData(em, 'A,B', undefined, true, true, undefined, true);
// 'A,B' exactly matches a validValue, treated as a single value (not split on comma)
expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }]));
expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined();
});

test('pasting A, B with space into mvtc parses as two values', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [genCellKey(mvtc, 0)],
selectedColIdx: 3,
selectedRowIdx: 0,
});
const changes = await validateAndInsertPastedData(em, 'A, B', undefined, true, true, undefined, true);
// 'A, B' does not match any validValue, so parsed as CSV → ' B' and 'A' (sorted with leading space)
expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(
List([
{ display: 'A', raw: 'A' },
{ display: 'B', raw: 'B' }
])
);
expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined();
});

test('pasting quoted "A,B" into mvtc treats as single value', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [genCellKey(mvtc, 0)],
selectedColIdx: 3,
selectedRowIdx: 0,
});
const changes = await validateAndInsertPastedData(em, '"A,B"', undefined, true, true, undefined, true);
// Quoted '"A,B"' is CSV-parsed to 'A,B' which is a valid value
expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }]));
expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined();
});

test('pasting quoted "A, B" into mvtc, invalid', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [genCellKey(mvtc, 0)],
selectedColIdx: 3,
selectedRowIdx: 0,
});
const changes = await validateAndInsertPastedData(em, '"A, B"', undefined, true, true, undefined, true);
// Quoted '"A,B"' is CSV-parsed to 'A,B' which is a valid value
expect(changes.cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A, B', raw: 'A, B' }]));
expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toEqual({
message: 'Could not find "A, B". Please make sure values that contain commas are properly quoted.',
});
});

test('pasting mvtc values combined with other valid values', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [genCellKey(mvtc, 0), genCellKey(mvtc, 1), genCellKey(mvtc, 2)],
selectedColIdx: 3,
selectedRowIdx: 2,
});
const changes = await validateAndInsertPastedData(
em,
'A,B\n"A,B",cc\nA,B,cc',
undefined,
true,
true,
undefined,
true
);
const cellValues = changes.cellValues;
// Row 0: 'A,B' exactly matches single validValue
expect(cellValues.get(genCellKey(mvtc, 0))).toEqual(List([{ display: 'A,B', raw: 'A,B' }]));
expect(changes.cellMessages.get(genCellKey(mvtc, 0))).toBeUndefined();
// Row 1: '"A,B",cc' → CSV parsed to ['A,B', 'cc'], both valid
expect(cellValues.get(genCellKey(mvtc, 1))).toEqual(
List([
{ display: 'A,B', raw: 'A,B' },
{ display: 'cc', raw: 'cc' },
])
);
expect(changes.cellMessages.get(genCellKey(mvtc, 1))).toBeUndefined();
// Row 2: 'A,B,cc' without quotes → CSV parsed to ['A', 'B', 'cc'], all valid
expect(cellValues.get(genCellKey(mvtc, 2))).toEqual(
List([
{ display: 'A', raw: 'A' },
{ display: 'B', raw: 'B' },
{ display: 'cc', raw: 'cc' },
])
);
expect(changes.cellMessages.get(genCellKey(mvtc, 2))).toBeUndefined();
});

test('pasting mvtc values combined with invalid values', async () => {
const em = baseEditorModel.applyChanges({
selectionCells: [genCellKey(mvtc, 0), genCellKey(mvtc, 1)],
selectedColIdx: 3,
selectedRowIdx: 1,
});
const changes = await validateAndInsertPastedData(
em,
'A, B, bad\n"A,B",bad',
undefined,
true,
true,
undefined,
true
);
const cellValues = changes.cellValues;
const cellMessages = changes.cellMessages;
// Row 0: 'A,B,bad' → CSV parsed to ['A', 'B', 'bad'], 'bad' invalid
expect(cellValues.get(genCellKey(mvtc, 0))).toEqual(
List([
{ display: 'A', raw: 'A' },
{ display: 'B', raw: 'B' },
{ display: 'bad', raw: 'bad' },
])
);
expect(cellMessages.get(genCellKey(mvtc, 0))).toEqual({ message: 'Could not find "bad"' });
// Row 1: '"A,B",bad' → CSV parsed to ['A,B', 'bad'], 'bad' invalid
expect(cellValues.get(genCellKey(mvtc, 1))).toEqual(
List([
{ display: 'A,B', raw: 'A,B' },
{ display: 'bad', raw: 'bad' },
])
);
expect(cellMessages.get(genCellKey(mvtc, 1))).toEqual({ message: 'Could not find "bad"' });
});
});

describe('loadEditorModelData', () => {
Expand Down
Loading
Loading